diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 5f6e1cd99f3..809b0f6aa55 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -1,6 +1,6 @@ # Code - OSS Development Container -[![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/microsoft/vscode) +[![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/microsoft/vscode) This repository includes configuration for a development container for working with Code - OSS in a local container or using [GitHub Codespaces](https://github.com/features/codespaces). diff --git a/.npmrc b/.npmrc index e3a35cce683..30ecf60a970 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="32.2.1" -ms_build_id="10427718" +target="32.2.3" +ms_build_id="10561341" runtime="electron" build_from_source="true" legacy-peer-deps="true" diff --git a/build/azure-pipelines/cli/cli-darwin-sign.yml b/build/azure-pipelines/cli/cli-darwin-sign.yml index bd04c8f72bc..ba8150651a7 100644 --- a/build/azure-pipelines/cli/cli-darwin-sign.yml +++ b/build/azure-pipelines/cli/cli-darwin-sign.yml @@ -4,20 +4,21 @@ parameters: default: [] steps: - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get ESRP Secrets" - inputs: - azureSubscription: vscode-esrp - KeyVaultName: vscode-esrp - SecretsFilter: "esrp-sign-legacy,esrp-aad-username,esrp-aad-password" - - task: UseDotNet@2 inputs: version: 6.x - - task: EsrpClientTool@1 - continueOnError: true - displayName: Download ESRPClient + - task: EsrpCodeSigning@5 + inputs: + UseMSIAuthentication: true + ConnectedServiceName: vscode-esrp + AppRegistrationClientId: $(ESRP_CLIENT_ID) + AppRegistrationTenantId: $(ESRP_TENANT_ID) + AuthAKVName: vscode-esrp + AuthSignCertName: esrp-sign + FolderPath: . + Pattern: noop + displayName: 'Install ESRP Tooling' - ${{ each target in parameters.VSCODE_CLI_ARTIFACTS }}: - task: DownloadPipelineArtifact@2 @@ -32,10 +33,14 @@ steps: archiveFilePatterns: $(Build.ArtifactStagingDirectory)/pkg/${{ target }}/*.zip destinationFolder: $(Build.ArtifactStagingDirectory)/sign/${{ target }} - - script: node build/azure-pipelines/common/sign $(Agent.ToolsDirectory)/esrpclient/*/*/net6.0/esrpcli.dll sign-darwin $(esrp-sign-legacy) $(esrp-aad-username) $(esrp-aad-password) $(Build.ArtifactStagingDirectory)/pkg "*.zip" + - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll sign-darwin $(Build.ArtifactStagingDirectory)/pkg "*.zip" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Codesign - - script: node build/azure-pipelines/common/sign $(Agent.ToolsDirectory)/esrpclient/*/*/net6.0/esrpcli.dll notarize-darwin $(esrp-sign-legacy) $(esrp-aad-username) $(esrp-aad-password) $(Build.ArtifactStagingDirectory)/pkg "*.zip" + - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll notarize-darwin $(Build.ArtifactStagingDirectory)/pkg "*.zip" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Notarize - ${{ each target in parameters.VSCODE_CLI_ARTIFACTS }}: diff --git a/build/azure-pipelines/cli/cli-win32-sign.yml b/build/azure-pipelines/cli/cli-win32-sign.yml index 81f1a001489..bc711bec4a7 100644 --- a/build/azure-pipelines/cli/cli-win32-sign.yml +++ b/build/azure-pipelines/cli/cli-win32-sign.yml @@ -4,19 +4,29 @@ parameters: default: [] steps: - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get ESRP Secrets" - inputs: - azureSubscription: vscode-esrp - KeyVaultName: vscode-esrp - SecretsFilter: "esrp-sign-legacy,esrp-aad-username,esrp-aad-password" - - task: UseDotNet@2 inputs: version: 6.x - - task: EsrpClientTool@1 - displayName: "Use ESRP client" + - task: EsrpCodeSigning@5 + inputs: + UseMSIAuthentication: true + ConnectedServiceName: vscode-esrp + AppRegistrationClientId: $(ESRP_CLIENT_ID) + AppRegistrationTenantId: $(ESRP_TENANT_ID) + AuthAKVName: vscode-esrp + AuthSignCertName: esrp-sign + FolderPath: . + Pattern: noop + displayName: 'Install ESRP Tooling' + + - powershell: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + $EsrpCodeSigningTool = (gci -directory -filter EsrpCodeSigning_* $(Agent.RootDirectory)\_tasks | Select-Object -last 1).FullName + $Version = (gci -directory $EsrpCodeSigningTool | Select-Object -last 1).FullName + echo "##vso[task.setvariable variable=EsrpCliDllPath]$Version\net6.0\esrpcli.dll" + displayName: Find ESRP CLI - ${{ each target in parameters.VSCODE_CLI_ARTIFACTS }}: - task: DownloadPipelineArtifact@2 @@ -31,18 +41,9 @@ steps: archiveFilePatterns: $(Build.ArtifactStagingDirectory)/pkg/${{ target }}/*.zip destinationFolder: $(Build.ArtifactStagingDirectory)/sign/${{ target }} - - powershell: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - $EsrpClientTool = (gci -directory -filter EsrpClientTool_* $(Agent.RootDirectory)\_tasks | Select-Object -last 1).FullName - $EsrpCliZip = (gci -recurse -filter esrpcli.*.zip $EsrpClientTool | Select-Object -last 1).FullName - mkdir -p $(Agent.TempDirectory)\esrpcli - Expand-Archive -Path $EsrpCliZip -DestinationPath $(Agent.TempDirectory)\esrpcli - $EsrpCliDllPath = (gci -recurse -filter esrpcli.dll $(Agent.TempDirectory)\esrpcli | Select-Object -last 1).FullName - echo "##vso[task.setvariable variable=EsrpCliDllPath]$EsrpCliDllPath" - displayName: Find ESRP CLI - - - powershell: node build\azure-pipelines\common\sign $env:EsrpCliDllPath sign-windows $(esrp-sign-legacy) $(esrp-aad-username) $(esrp-aad-password) $(Build.ArtifactStagingDirectory)/sign "*.exe" + - powershell: node build\azure-pipelines\common\sign $env:EsrpCliDllPath sign-windows $(Build.ArtifactStagingDirectory)/sign "*.exe" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Codesign - ${{ each target in parameters.VSCODE_CLI_ARTIFACTS }}: diff --git a/build/azure-pipelines/common/getPublishAuthTokens.js b/build/azure-pipelines/common/getPublishAuthTokens.js new file mode 100644 index 00000000000..9c22e9ad94b --- /dev/null +++ b/build/azure-pipelines/common/getPublishAuthTokens.js @@ -0,0 +1,47 @@ +"use strict"; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getAccessToken = getAccessToken; +const msal_node_1 = require("@azure/msal-node"); +function e(name) { + const result = process.env[name]; + if (typeof result !== 'string') { + throw new Error(`Missing env: ${name}`); + } + return result; +} +async function getAccessToken(endpoint, tenantId, clientId, idToken) { + const app = new msal_node_1.ConfidentialClientApplication({ + auth: { + clientId, + authority: `https://login.microsoftonline.com/${tenantId}`, + clientAssertion: idToken + } + }); + const result = await app.acquireTokenByClientCredential({ scopes: [`${endpoint}.default`] }); + if (!result) { + throw new Error('Failed to get access token'); + } + return { + token: result.accessToken, + expiresOnTimestamp: result.expiresOn.getTime(), + refreshAfterTimestamp: result.refreshOn?.getTime() + }; +} +async function main() { + const cosmosDBAccessToken = await getAccessToken(e('AZURE_DOCUMENTDB_ENDPOINT'), e('AZURE_TENANT_ID'), e('AZURE_CLIENT_ID'), e('AZURE_ID_TOKEN')); + const blobServiceAccessToken = await getAccessToken(`https://${e('VSCODE_STAGING_BLOB_STORAGE_ACCOUNT_NAME')}.blob.core.windows.net/`, process.env['AZURE_TENANT_ID'], process.env['AZURE_CLIENT_ID'], process.env['AZURE_ID_TOKEN']); + console.log(JSON.stringify({ cosmosDBAccessToken, blobServiceAccessToken })); +} +if (require.main === module) { + main().then(() => { + process.exit(0); + }, err => { + console.error(err); + process.exit(1); + }); +} +//# sourceMappingURL=getPublishAuthTokens.js.map \ No newline at end of file diff --git a/build/azure-pipelines/common/getPublishAuthTokens.ts b/build/azure-pipelines/common/getPublishAuthTokens.ts new file mode 100644 index 00000000000..68e76de1a83 --- /dev/null +++ b/build/azure-pipelines/common/getPublishAuthTokens.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AccessToken } from '@azure/core-auth'; +import { ConfidentialClientApplication } from '@azure/msal-node'; + +function e(name: string): string { + const result = process.env[name]; + + if (typeof result !== 'string') { + throw new Error(`Missing env: ${name}`); + } + + return result; +} + +export async function getAccessToken(endpoint: string, tenantId: string, clientId: string, idToken: string): Promise { + const app = new ConfidentialClientApplication({ + auth: { + clientId, + authority: `https://login.microsoftonline.com/${tenantId}`, + clientAssertion: idToken + } + }); + + const result = await app.acquireTokenByClientCredential({ scopes: [`${endpoint}.default`] }); + + if (!result) { + throw new Error('Failed to get access token'); + } + + return { + token: result.accessToken, + expiresOnTimestamp: result.expiresOn!.getTime(), + refreshAfterTimestamp: result.refreshOn?.getTime() + }; +} + +async function main() { + const cosmosDBAccessToken = await getAccessToken(e('AZURE_DOCUMENTDB_ENDPOINT')!, e('AZURE_TENANT_ID')!, e('AZURE_CLIENT_ID')!, e('AZURE_ID_TOKEN')!); + const blobServiceAccessToken = await getAccessToken(`https://${e('VSCODE_STAGING_BLOB_STORAGE_ACCOUNT_NAME')}.blob.core.windows.net/`, process.env['AZURE_TENANT_ID']!, process.env['AZURE_CLIENT_ID']!, process.env['AZURE_ID_TOKEN']!); + console.log(JSON.stringify({ cosmosDBAccessToken, blobServiceAccessToken })); +} + +if (require.main === module) { + main().then(() => { + process.exit(0); + }, err => { + console.error(err); + process.exit(1); + }); +} diff --git a/build/azure-pipelines/common/publish.js b/build/azure-pipelines/common/publish.js index 5b7acc20001..3816db385a0 100644 --- a/build/azure-pipelines/common/publish.js +++ b/build/azure-pipelines/common/publish.js @@ -4,7 +4,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.getAccessToken = getAccessToken; const fs = require("fs"); const path = require("path"); const stream_1 = require("stream"); @@ -13,10 +12,12 @@ const yauzl = require("yauzl"); const crypto = require("crypto"); const retry_1 = require("./retry"); const cosmos_1 = require("@azure/cosmos"); -const identity_1 = require("@azure/identity"); const cp = require("child_process"); const os = require("os"); const node_worker_threads_1 = require("node:worker_threads"); +const msal_node_1 = require("@azure/msal-node"); +const storage_blob_1 = require("@azure/storage-blob"); +const jws = require("jws"); function e(name) { const result = process.env[name]; if (typeof result !== 'string') { @@ -24,267 +25,236 @@ function e(name) { } return result; } -class Temp { - _files = []; - tmpNameSync() { - const file = path.join(os.tmpdir(), crypto.randomBytes(20).toString('hex')); - this._files.push(file); - return file; - } - dispose() { - for (const file of this._files) { - try { - fs.unlinkSync(file); - } - catch (err) { - // noop - } - } - } -} -/** - * Gets an access token converted from a WIF/OIDC id token. - * We need this since this build job takes a while to run and while id tokens live for 10 minutes only, access tokens live for 24 hours. - * Source: https://goodworkaround.com/2021/12/21/another-deep-dive-into-azure-ad-workload-identity-federation-using-github-actions/ - */ -async function getAccessToken(endpoint, tenantId, clientId, idToken) { - const body = new URLSearchParams({ - scope: `${endpoint}.default`, - client_id: clientId, - grant_type: 'client_credentials', - client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', - client_assertion: encodeURIComponent(idToken) - }); - const response = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: body.toString() - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const aadToken = await response.json(); - return aadToken.access_token; -} -function isCreateProvisionedFilesErrorResponse(response) { - return response?.ErrorDetails?.Code !== undefined; -} -class ProvisionService { - log; - accessToken; - constructor(log, accessToken) { - this.log = log; - this.accessToken = accessToken; - } - async provision(releaseId, fileId, fileName) { - const body = JSON.stringify({ - ReleaseId: releaseId, - PortalName: 'VSCode', - PublisherCode: 'VSCode', - ProvisionedFilesCollection: [{ - PublisherKey: fileId, - IsStaticFriendlyFileName: true, - FriendlyFileName: fileName, - MaxTTL: '1440', - CdnMappings: ['ECN'] - }] - }); - this.log(`Provisioning ${fileName} (releaseId: ${releaseId}, fileId: ${fileId})...`); - const res = await (0, retry_1.retry)(() => this.request('POST', '/api/v2/ProvisionedFiles/CreateProvisionedFiles', { body })); - if (isCreateProvisionedFilesErrorResponse(res) && res.ErrorDetails.Code === 'FriendlyFileNameAlreadyProvisioned') { - this.log(`File already provisioned (most likley due to a re-run), skipping: ${fileName}`); - return; - } - if (!res.IsSuccess) { - throw new Error(`Failed to submit provisioning request: ${JSON.stringify(res.ErrorDetails)}`); - } - this.log(`Successfully provisioned ${fileName}`); - } - async request(method, url, options) { - const opts = { - method, - body: options?.body, - headers: { - Authorization: `Bearer ${this.accessToken}`, - 'Content-Type': 'application/json' - } - }; - const res = await fetch(`https://dsprovisionapi.microsoft.com${url}`, opts); - // 400 normally means the request is bad or something is already provisioned, so we will return as retries are useless - // Otherwise log the text body and headers. We do text because some responses are not JSON. - if ((!res.ok || res.status < 200 || res.status >= 500) && res.status !== 400) { - throw new Error(`Unexpected status code: ${res.status}\nResponse Headers: ${JSON.stringify(res.headers)}\nBody Text: ${await res.text()}`); - } - return await res.json(); - } -} function hashStream(hashName, stream) { return new Promise((c, e) => { const shasum = crypto.createHash(hashName); stream .on('data', shasum.update.bind(shasum)) .on('error', e) - .on('close', () => c(shasum.digest('hex'))); + .on('close', () => c(shasum.digest())); }); } -class ESRPClient { - log; - tmp; - authPath; - constructor(log, tmp, tenantId, clientId, authCertSubjectName, requestSigningCertSubjectName) { - this.log = log; - this.tmp = tmp; - this.authPath = this.tmp.tmpNameSync(); - fs.writeFileSync(this.authPath, JSON.stringify({ - Version: '1.0.0', - AuthenticationType: 'AAD_CERT', - TenantId: tenantId, - ClientId: clientId, - AuthCert: { - SubjectName: authCertSubjectName, - StoreLocation: 'LocalMachine', - StoreName: 'My', - SendX5c: 'true' - }, - RequestSigningCert: { - SubjectName: requestSigningCertSubjectName, - StoreLocation: 'LocalMachine', - StoreName: 'My' - } - })); - } - async release(version, filePath) { - this.log(`Submitting release for ${version}: ${filePath}`); - const submitReleaseResult = await this.SubmitRelease(version, filePath); - if (submitReleaseResult.submissionResponse.statusCode !== 'pass') { - throw new Error(`Unexpected status code: ${submitReleaseResult.submissionResponse.statusCode}`); - } - const releaseId = submitReleaseResult.submissionResponse.operationId; - this.log(`Successfully submitted release ${releaseId}. Polling for completion...`); - let details; - // Poll every 5 seconds, wait 60 minutes max -> poll 60/5*60=720 times - for (let i = 0; i < 720; i++) { - details = await this.ReleaseDetails(releaseId); - if (details.releaseDetails[0].statusCode === 'pass') { - break; - } - else if (details.releaseDetails[0].statusCode !== 'inprogress') { - throw new Error(`Failed to submit release: ${JSON.stringify(details)}`); - } - await new Promise(c => setTimeout(c, 5000)); - } - if (details.releaseDetails[0].statusCode !== 'pass') { - throw new Error(`Timed out waiting for release ${releaseId}: ${JSON.stringify(details)}`); - } - const fileId = details.releaseDetails[0].fileDetails[0].publisherKey; - this.log('Release completed successfully with fileId: ', fileId); - return { releaseId, fileId }; - } - async SubmitRelease(version, filePath) { - const policyPath = this.tmp.tmpNameSync(); - fs.writeFileSync(policyPath, JSON.stringify({ - Version: '1.0.0', - Audience: 'InternalLimited', - Intent: 'distribution', - ContentType: 'InstallPackage' - })); - const inputPath = this.tmp.tmpNameSync(); - const size = fs.statSync(filePath).size; - const istream = fs.createReadStream(filePath); - const sha256 = await hashStream('sha256', istream); - fs.writeFileSync(inputPath, JSON.stringify({ - Version: '1.0.0', - ReleaseInfo: { - ReleaseMetadata: { - Title: 'VS Code', - Properties: { - ReleaseContentType: 'InstallPackage' - }, - MinimumNumberOfApprovers: 1 - }, - ProductInfo: { - Name: 'VS Code', - Version: version, - Description: path.basename(filePath, path.extname(filePath)), - }, - Owners: [ - { - Owner: { - UserPrincipalName: 'jomo@microsoft.com' - } - } - ], - Approvers: [ - { - Approver: { - UserPrincipalName: 'jomo@microsoft.com' - }, - IsAutoApproved: true, - IsMandatory: false - } - ], - AccessPermissions: { - MainPublisher: 'VSCode', - ChannelDownloadEntityDetails: { - Consumer: ['VSCode'] - } - }, - CreatedBy: { - UserPrincipalName: 'jomo@microsoft.com' - } - }, - ReleaseBatches: [ - { - ReleaseRequestFiles: [ - { - SizeInBytes: size, - SourceHash: sha256, - HashType: 'SHA256', - SourceLocation: path.basename(filePath) - } - ], - SourceLocationType: 'UNC', - SourceRootDirectory: path.dirname(filePath), - DestinationLocationType: 'AzureBlob' - } - ] - })); - const outputPath = this.tmp.tmpNameSync(); - cp.execSync(`ESRPClient SubmitRelease -a ${this.authPath} -p ${policyPath} -i ${inputPath} -o ${outputPath}`, { stdio: 'inherit' }); - const output = fs.readFileSync(outputPath, 'utf8'); - return JSON.parse(output); - } - async ReleaseDetails(releaseId) { - const inputPath = this.tmp.tmpNameSync(); - fs.writeFileSync(inputPath, JSON.stringify({ - Version: '1.0.0', - OperationIds: [releaseId] - })); - const outputPath = this.tmp.tmpNameSync(); - cp.execSync(`ESRPClient ReleaseDetails -a ${this.authPath} -i ${inputPath} -o ${outputPath}`, { stdio: 'inherit' }); - const output = fs.readFileSync(outputPath, 'utf8'); - return JSON.parse(output); - } +var StatusCode; +(function (StatusCode) { + StatusCode["Pass"] = "pass"; + StatusCode["Inprogress"] = "inprogress"; + StatusCode["FailCanRetry"] = "failCanRetry"; + StatusCode["FailDoNotRetry"] = "failDoNotRetry"; + StatusCode["PendingAnalysis"] = "pendingAnalysis"; + StatusCode["Cancelled"] = "cancelled"; +})(StatusCode || (StatusCode = {})); +function getCertificateBuffer(input) { + return Buffer.from(input.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\n/g, ''), 'base64'); } -async function releaseAndProvision(log, releaseTenantId, releaseClientId, releaseAuthCertSubjectName, releaseRequestSigningCertSubjectName, provisionTenantId, provisionAADUsername, provisionAADPassword, version, quality, filePath) { - const fileName = `${quality}/${version}/${path.basename(filePath)}`; - const result = `${e('PRSS_CDN_URL')}/${fileName}`; - const res = await (0, retry_1.retry)(() => fetch(result)); - if (res.status === 200) { - log(`Already released and provisioned: ${result}`); +function getThumbprint(input, algorithm) { + const buffer = getCertificateBuffer(input); + return crypto.createHash(algorithm).update(buffer).digest(); +} +function getKeyFromPFX(pfx) { + const pfxCertificatePath = path.join(os.tmpdir(), 'cert.pfx'); + const pemKeyPath = path.join(os.tmpdir(), 'key.pem'); + try { + const pfxCertificate = Buffer.from(pfx, 'base64'); + fs.writeFileSync(pfxCertificatePath, pfxCertificate); + cp.execSync(`openssl pkcs12 -in "${pfxCertificatePath}" -nocerts -nodes -out "${pemKeyPath}" -passin pass:`); + const raw = fs.readFileSync(pemKeyPath, 'utf-8'); + const result = raw.match(/-----BEGIN PRIVATE KEY-----[\s\S]+?-----END PRIVATE KEY-----/g)[0]; return result; } - const tmp = new Temp(); - process.on('exit', () => tmp.dispose()); - const esrpclient = new ESRPClient(log, tmp, releaseTenantId, releaseClientId, releaseAuthCertSubjectName, releaseRequestSigningCertSubjectName); - const release = await esrpclient.release(version, filePath); - const credential = new identity_1.ClientSecretCredential(provisionTenantId, provisionAADUsername, provisionAADPassword); - const accessToken = await credential.getToken(['https://microsoft.onmicrosoft.com/DS.Provisioning.WebApi/.default']); - const service = new ProvisionService(log, accessToken.token); - await service.provision(release.releaseId, release.fileId, fileName); - return result; + finally { + fs.rmSync(pfxCertificatePath, { force: true }); + fs.rmSync(pemKeyPath, { force: true }); + } +} +function getCertificatesFromPFX(pfx) { + const pfxCertificatePath = path.join(os.tmpdir(), 'cert.pfx'); + const pemCertificatePath = path.join(os.tmpdir(), 'cert.pem'); + try { + const pfxCertificate = Buffer.from(pfx, 'base64'); + fs.writeFileSync(pfxCertificatePath, pfxCertificate); + cp.execSync(`openssl pkcs12 -in "${pfxCertificatePath}" -nokeys -out "${pemCertificatePath}" -passin pass:`); + const raw = fs.readFileSync(pemCertificatePath, 'utf-8'); + const matches = raw.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g); + return matches ? matches.reverse() : []; + } + finally { + fs.rmSync(pfxCertificatePath, { force: true }); + fs.rmSync(pemCertificatePath, { force: true }); + } +} +class ESRPReleaseService { + log; + clientId; + accessToken; + requestSigningCertificates; + requestSigningKey; + containerClient; + static async create(log, tenantId, clientId, authCertificatePfx, requestSigningCertificatePfx, containerClient) { + const authKey = getKeyFromPFX(authCertificatePfx); + const authCertificate = getCertificatesFromPFX(authCertificatePfx)[0]; + const requestSigningKey = getKeyFromPFX(requestSigningCertificatePfx); + const requestSigningCertificates = getCertificatesFromPFX(requestSigningCertificatePfx); + const app = new msal_node_1.ConfidentialClientApplication({ + auth: { + clientId, + authority: `https://login.microsoftonline.com/${tenantId}`, + clientCertificate: { + thumbprintSha256: getThumbprint(authCertificate, 'sha256').toString('hex'), + privateKey: authKey, + x5c: authCertificate + } + } + }); + const response = await app.acquireTokenByClientCredential({ + scopes: ['https://api.esrp.microsoft.com/.default'] + }); + return new ESRPReleaseService(log, clientId, response.accessToken, requestSigningCertificates, requestSigningKey, containerClient); + } + static API_URL = 'https://api.esrp.microsoft.com/api/v3/releaseservices/clients/'; + constructor(log, clientId, accessToken, requestSigningCertificates, requestSigningKey, containerClient) { + this.log = log; + this.clientId = clientId; + this.accessToken = accessToken; + this.requestSigningCertificates = requestSigningCertificates; + this.requestSigningKey = requestSigningKey; + this.containerClient = containerClient; + } + async createRelease(version, filePath, friendlyFileName) { + const correlationId = crypto.randomUUID(); + const blobClient = this.containerClient.getBlockBlobClient(correlationId); + this.log(`Uploading ${filePath} to ${blobClient.url}`); + await blobClient.uploadFile(filePath); + this.log('Uploaded blob successfully'); + try { + this.log(`Submitting release for ${version}: ${filePath}`); + const submitReleaseResult = await this.submitRelease(version, filePath, friendlyFileName, correlationId, blobClient); + this.log(`Successfully submitted release ${submitReleaseResult.operationId}. Polling for completion...`); + // Poll every 5 seconds, wait 60 minutes max -> poll 60/5*60=720 times + for (let i = 0; i < 720; i++) { + await new Promise(c => setTimeout(c, 5000)); + const releaseStatus = await this.getReleaseStatus(submitReleaseResult.operationId); + if (releaseStatus.status === 'pass') { + break; + } + else if (releaseStatus.status !== 'inprogress') { + throw new Error(`Failed to submit release: ${JSON.stringify(releaseStatus)}`); + } + } + const releaseDetails = await this.getReleaseDetails(submitReleaseResult.operationId); + if (releaseDetails.status !== 'pass') { + throw new Error(`Timed out waiting for release: ${JSON.stringify(releaseDetails)}`); + } + this.log('Successfully created release:', releaseDetails.files[0].fileDownloadDetails[0].downloadUrl); + return releaseDetails.files[0].fileDownloadDetails[0].downloadUrl; + } + finally { + this.log(`Deleting blob ${blobClient.url}`); + await blobClient.delete(); + this.log('Deleted blob successfully'); + } + } + async submitRelease(version, filePath, friendlyFileName, correlationId, blobClient) { + const size = fs.statSync(filePath).size; + const hash = await hashStream('sha256', fs.createReadStream(filePath)); + const message = { + customerCorrelationId: correlationId, + esrpCorrelationId: correlationId, + driEmail: ['joao.moreno@microsoft.com'], + createdBy: { userPrincipalName: 'jomo@microsoft.com' }, + owners: [{ owner: { userPrincipalName: 'jomo@microsoft.com' } }], + approvers: [{ approver: { userPrincipalName: 'jomo@microsoft.com' }, isAutoApproved: true, isMandatory: false }], + releaseInfo: { + title: 'VS Code', + properties: { + 'ReleaseContentType': 'InstallPackage' + }, + minimumNumberOfApprovers: 1 + }, + productInfo: { + name: 'VS Code', + version, + description: 'VS Code' + }, + accessPermissionsInfo: { + mainPublisher: 'VSCode', + channelDownloadEntityDetails: { + AllDownloadEntities: ['VSCode'] + } + }, + routingInfo: { + intent: 'filedownloadlinkgeneration' + }, + files: [{ + name: path.basename(filePath), + friendlyFileName, + tenantFileLocation: blobClient.url, + tenantFileLocationType: 'AzureBlob', + sourceLocation: { + type: 'azureBlob', + blobUrl: blobClient.url + }, + hashType: 'sha256', + hash: Array.from(hash), + sizeInBytes: size + }] + }; + message.jwsToken = await this.generateJwsToken(message); + const res = await fetch(`${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.accessToken}` + }, + body: JSON.stringify(message) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to submit release: ${res.statusText}\n${text}`); + } + return await res.json(); + } + async getReleaseStatus(releaseId) { + const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grs/${releaseId}`; + const res = await fetch(url, { + headers: { + 'Authorization': `Bearer ${this.accessToken}` + } + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to get release status: ${res.statusText}\n${text}`); + } + return await res.json(); + } + async getReleaseDetails(releaseId) { + const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grd/${releaseId}`; + const res = await fetch(url, { + headers: { + 'Authorization': `Bearer ${this.accessToken}` + } + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to get release status: ${res.statusText}\n${text}`); + } + return await res.json(); + } + async generateJwsToken(message) { + return jws.sign({ + header: { + alg: 'RS256', + crit: ['exp', 'x5t'], + // Release service uses ticks, not seconds :roll_eyes: (https://stackoverflow.com/a/7968483) + exp: ((Date.now() + (6 * 60 * 1000)) * 10000) + 621355968000000000, + // Release service uses hex format, not base64url :roll_eyes: + x5t: getThumbprint(this.requestSigningCertificates[0], 'sha1').toString('hex'), + // Release service uses a '.' separated string, not an array of strings :roll_eyes: + x5c: this.requestSigningCertificates.map(c => getCertificateBuffer(c).toString('base64url')).join('.'), + }, + payload: message, + privateKey: this.requestSigningKey, + }); + } } class State { statePath; @@ -500,30 +470,42 @@ function getRealType(type) { return type; } } -async function processArtifact(artifact, artifactFilePath, cosmosDBAccessToken) { - const log = (...args) => console.log(`[${artifact.name}]`, ...args); +async function processArtifact(artifact, filePath) { const match = /^vscode_(?[^_]+)_(?[^_]+)(?:_legacy)?_(?[^_]+)_(?[^_]+)$/.exec(artifact.name); if (!match) { throw new Error(`Invalid artifact name: ${artifact.name}`); } // getPlatform needs the unprocessedType + const { cosmosDBAccessToken, blobServiceAccessToken } = JSON.parse(e('PUBLISH_AUTH_TOKENS')); const quality = e('VSCODE_QUALITY'); - const commit = e('BUILD_SOURCEVERSION'); + const version = e('BUILD_SOURCEVERSION'); const { product, os, arch, unprocessedType } = match.groups; const isLegacy = artifact.name.includes('_legacy'); const platform = getPlatform(product, os, arch, unprocessedType, isLegacy); const type = getRealType(unprocessedType); - const size = fs.statSync(artifactFilePath).size; - const stream = fs.createReadStream(artifactFilePath); + const size = fs.statSync(filePath).size; + const stream = fs.createReadStream(filePath); const [hash, sha256hash] = await Promise.all([hashStream('sha1', stream), hashStream('sha256', stream)]); // CodeQL [SM04514] Using SHA1 only for legacy reasons, we are actually only respecting SHA256 - const url = await releaseAndProvision(log, e('RELEASE_TENANT_ID'), e('RELEASE_CLIENT_ID'), e('RELEASE_AUTH_CERT_SUBJECT_NAME'), e('RELEASE_REQUEST_SIGNING_CERT_SUBJECT_NAME'), e('PROVISION_TENANT_ID'), e('PROVISION_AAD_USERNAME'), e('PROVISION_AAD_PASSWORD'), commit, quality, artifactFilePath); - const asset = { platform, type, url, hash, sha256hash, size, supportsFastUpdate: true }; + const log = (...args) => console.log(`[${artifact.name}]`, ...args); + const blobServiceClient = new storage_blob_1.BlobServiceClient(`https://${e('VSCODE_STAGING_BLOB_STORAGE_ACCOUNT_NAME')}.blob.core.windows.net/`, { getToken: async () => blobServiceAccessToken }); + const containerClient = blobServiceClient.getContainerClient('staging'); + const releaseService = await ESRPReleaseService.create(log, e('RELEASE_TENANT_ID'), e('RELEASE_CLIENT_ID'), e('RELEASE_AUTH_CERT'), e('RELEASE_REQUEST_SIGNING_CERT'), containerClient); + const friendlyFileName = `${quality}/${version}/${path.basename(filePath)}`; + const url = `${e('PRSS_CDN_URL')}/${friendlyFileName}`; + const res = await (0, retry_1.retry)(() => fetch(url)); + if (res.status === 200) { + log(`Already released and provisioned: ${url}`); + } + else { + await releaseService.createRelease(version, filePath, friendlyFileName); + } + const asset = { platform, type, url, hash: hash.toString('hex'), sha256hash: sha256hash.toString('hex'), size, supportsFastUpdate: true }; log('Creating asset...', JSON.stringify(asset, undefined, 2)); await (0, retry_1.retry)(async (attempt) => { log(`Creating asset in Cosmos DB (attempt ${attempt})...`); - const client = new cosmos_1.CosmosClient({ endpoint: e('AZURE_DOCUMENTDB_ENDPOINT'), tokenProvider: () => Promise.resolve(`type=aad&ver=1.0&sig=${cosmosDBAccessToken}`) }); + const client = new cosmos_1.CosmosClient({ endpoint: e('AZURE_DOCUMENTDB_ENDPOINT'), tokenProvider: () => Promise.resolve(`type=aad&ver=1.0&sig=${cosmosDBAccessToken.token}`) }); const scripts = client.database('builds').container(quality).scripts; - await scripts.storedProcedure('createAsset').execute('', [commit, asset, true]); + await scripts.storedProcedure('createAsset').execute('', [version, asset, true]); }); log('Asset successfully created'); } @@ -535,8 +517,8 @@ async function processArtifact(artifact, artifactFilePath, cosmosDBAccessToken) // the CDN and finally update the build in Cosmos DB. async function main() { if (!node_worker_threads_1.isMainThread) { - const { artifact, artifactFilePath, cosmosDBAccessToken } = node_worker_threads_1.workerData; - await processArtifact(artifact, artifactFilePath, cosmosDBAccessToken); + const { artifact, artifactFilePath } = node_worker_threads_1.workerData; + await processArtifact(artifact, artifactFilePath); return; } const done = new State(); @@ -565,7 +547,6 @@ async function main() { } let resultPromise = Promise.resolve([]); const operations = []; - const cosmosDBAccessToken = await getAccessToken(e('AZURE_DOCUMENTDB_ENDPOINT'), e('AZURE_TENANT_ID'), e('AZURE_CLIENT_ID'), e('AZURE_ID_TOKEN')); while (true) { const [timeline, artifacts] = await Promise.all([(0, retry_1.retry)(() => getPipelineTimeline()), (0, retry_1.retry)(() => getPipelineArtifacts())]); const stagesCompleted = new Set(timeline.records.filter(r => r.type === 'Stage' && r.state === 'completed' && stages.has(r.name)).map(r => r.name)); @@ -602,7 +583,7 @@ async function main() { const artifactFilePath = artifactFilePaths.filter(p => !/_manifest/.test(p))[0]; processing.add(artifact.name); const promise = new Promise((resolve, reject) => { - const worker = new node_worker_threads_1.Worker(__filename, { workerData: { artifact, artifactFilePath, cosmosDBAccessToken } }); + const worker = new node_worker_threads_1.Worker(__filename, { workerData: { artifact, artifactFilePath } }); worker.on('error', reject); worker.on('exit', code => { if (code === 0) { diff --git a/build/azure-pipelines/common/publish.ts b/build/azure-pipelines/common/publish.ts index 37ed4232f38..a3760c03434 100644 --- a/build/azure-pipelines/common/publish.ts +++ b/build/azure-pipelines/common/publish.ts @@ -12,10 +12,12 @@ import * as yauzl from 'yauzl'; import * as crypto from 'crypto'; import { retry } from './retry'; import { CosmosClient } from '@azure/cosmos'; -import { ClientSecretCredential } from '@azure/identity'; import * as cp from 'child_process'; import * as os from 'os'; import { Worker, isMainThread, workerData } from 'node:worker_threads'; +import { ConfidentialClientApplication } from '@azure/msal-node'; +import { BlobClient, BlobServiceClient, ContainerClient } from '@azure/storage-blob'; +import * as jws from 'jws'; function e(name: string): string { const result = process.env[name]; @@ -27,375 +29,495 @@ function e(name: string): string { return result; } -class Temp { - private _files: string[] = []; - - tmpNameSync(): string { - const file = path.join(os.tmpdir(), crypto.randomBytes(20).toString('hex')); - this._files.push(file); - return file; - } - - dispose(): void { - for (const file of this._files) { - try { - fs.unlinkSync(file); - } catch (err) { - // noop - } - } - } -} - -/** - * Gets an access token converted from a WIF/OIDC id token. - * We need this since this build job takes a while to run and while id tokens live for 10 minutes only, access tokens live for 24 hours. - * Source: https://goodworkaround.com/2021/12/21/another-deep-dive-into-azure-ad-workload-identity-federation-using-github-actions/ - */ -export async function getAccessToken(endpoint: string, tenantId: string, clientId: string, idToken: string): Promise { - const body = new URLSearchParams({ - scope: `${endpoint}.default`, - client_id: clientId, - grant_type: 'client_credentials', - client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', - client_assertion: encodeURIComponent(idToken) - }); - - const response = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: body.toString() - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const aadToken = await response.json(); - return aadToken.access_token; -} - -interface RequestOptions { - readonly body?: string; -} - -interface CreateProvisionedFilesSuccessResponse { - IsSuccess: true; - ErrorDetails: null; -} - -interface CreateProvisionedFilesErrorResponse { - IsSuccess: false; - ErrorDetails: { - Code: string; - Category: string; - Message: string; - CanRetry: boolean; - AdditionalProperties: Record; - }; -} - -type CreateProvisionedFilesResponse = CreateProvisionedFilesSuccessResponse | CreateProvisionedFilesErrorResponse; - -function isCreateProvisionedFilesErrorResponse(response: unknown): response is CreateProvisionedFilesErrorResponse { - return (response as CreateProvisionedFilesErrorResponse)?.ErrorDetails?.Code !== undefined; -} - -class ProvisionService { - - constructor( - private readonly log: (...args: any[]) => void, - private readonly accessToken: string - ) { } - - async provision(releaseId: string, fileId: string, fileName: string) { - const body = JSON.stringify({ - ReleaseId: releaseId, - PortalName: 'VSCode', - PublisherCode: 'VSCode', - ProvisionedFilesCollection: [{ - PublisherKey: fileId, - IsStaticFriendlyFileName: true, - FriendlyFileName: fileName, - MaxTTL: '1440', - CdnMappings: ['ECN'] - }] - }); - - this.log(`Provisioning ${fileName} (releaseId: ${releaseId}, fileId: ${fileId})...`); - const res = await retry(() => this.request('POST', '/api/v2/ProvisionedFiles/CreateProvisionedFiles', { body })); - - if (isCreateProvisionedFilesErrorResponse(res) && res.ErrorDetails.Code === 'FriendlyFileNameAlreadyProvisioned') { - this.log(`File already provisioned (most likley due to a re-run), skipping: ${fileName}`); - return; - } - - if (!res.IsSuccess) { - throw new Error(`Failed to submit provisioning request: ${JSON.stringify(res.ErrorDetails)}`); - } - - this.log(`Successfully provisioned ${fileName}`); - } - - private async request(method: string, url: string, options?: RequestOptions): Promise { - const opts: RequestInit = { - method, - body: options?.body, - headers: { - Authorization: `Bearer ${this.accessToken}`, - 'Content-Type': 'application/json' - } - }; - - const res = await fetch(`https://dsprovisionapi.microsoft.com${url}`, opts); - - - // 400 normally means the request is bad or something is already provisioned, so we will return as retries are useless - // Otherwise log the text body and headers. We do text because some responses are not JSON. - if ((!res.ok || res.status < 200 || res.status >= 500) && res.status !== 400) { - throw new Error(`Unexpected status code: ${res.status}\nResponse Headers: ${JSON.stringify(res.headers)}\nBody Text: ${await res.text()}`); - } - - return await res.json(); - } -} - -function hashStream(hashName: string, stream: Readable): Promise { - return new Promise((c, e) => { +function hashStream(hashName: string, stream: Readable): Promise { + return new Promise((c, e) => { const shasum = crypto.createHash(hashName); stream .on('data', shasum.update.bind(shasum)) .on('error', e) - .on('close', () => c(shasum.digest('hex'))); + .on('close', () => c(shasum.digest())); }); } -interface Release { - readonly releaseId: string; - readonly fileId: string; +interface ReleaseSubmitResponse { + operationId: string; + esrpCorrelationId: string; + code?: string; + message?: string; + target?: string; + innerError?: any; } -interface SubmitReleaseResult { - submissionResponse: { - operationId: string; - statusCode: string; - }; +interface ReleaseActivityInfo { + activityId: string; + activityType: string; + name: string; + status: string; + errorCode: number; + errorMessages: string[]; + beginTime?: Date; + endTime?: Date; + lastModifiedAt?: Date; } -interface ReleaseDetailsResult { - releaseDetails: [{ - fileDetails: [{ publisherKey: string }]; - statusCode: 'inprogress' | 'pass'; - }]; +interface InnerServiceError { + code: string; + details: { [key: string]: string }; + innerError?: InnerServiceError; } -class ESRPClient { +interface ReleaseError { + errorCode: number; + errorMessages: string[]; +} - private readonly authPath: string; +const enum StatusCode { + Pass = 'pass', + Inprogress = 'inprogress', + FailCanRetry = 'failCanRetry', + FailDoNotRetry = 'failDoNotRetry', + PendingAnalysis = 'pendingAnalysis', + Cancelled = 'cancelled' +} - constructor( - private readonly log: (...args: any[]) => void, - private readonly tmp: Temp, +interface ReleaseResultMessage { + activities: ReleaseActivityInfo[]; + childWorkflowType: string; + clientId: string; + customerCorrelationId: string; + errorInfo: InnerServiceError; + groupId: string; + lastModifiedAt: Date; + operationId: string; + releaseError: ReleaseError; + requestSubmittedAt: Date; + routedRegion: string; + status: StatusCode; + totalFileCount: number; + totalReleaseSize: number; + version: string; +} + +interface ReleaseFileInfo { + name?: string; + hash?: number[]; + sourceLocation?: FileLocation; + sizeInBytes?: number; + hashType?: FileHashType; + fileId?: any; + distributionRelativePath?: string; + partNumber?: string; + friendlyFileName?: string; + tenantFileLocationType?: string; + tenantFileLocation?: string; + signedEngineeringCopyLocation?: string; + encryptedDistributionBlobLocation?: string; + preEncryptedDistributionBlobLocation?: string; + secondaryDistributionHashRequired?: boolean; + secondaryDistributionHashType?: FileHashType; + lastModifiedAt?: Date; + cultureCodes?: string[]; + displayFileInDownloadCenter?: boolean; + isPrimaryFileInDownloadCenter?: boolean; + fileDownloadDetails?: FileDownloadDetails[]; +} + +interface ReleaseDetailsFileInfo extends ReleaseFileInfo { } + +interface ReleaseDetailsMessage extends ReleaseResultMessage { + clusterRegion: string; + correlationVector: string; + releaseCompletedAt?: Date; + releaseInfo: ReleaseInfo; + productInfo: ProductInfo; + createdBy: UserInfo; + owners: OwnerInfo[]; + accessPermissionsInfo: AccessPermissionsInfo; + files: ReleaseDetailsFileInfo[]; + comments: string[]; + cancellationReason: string; + downloadCenterInfo: DownloadCenterInfo; +} + + +interface ProductInfo { + name?: string; + version?: string; + description?: string; +} + +interface ReleaseInfo { + title?: string; + minimumNumberOfApprovers: number; + properties?: { [key: string]: string }; + isRevision?: boolean; + revisionNumber?: string; +} + +type FileLocationType = 'azureBlob'; + +interface FileLocation { + type: FileLocationType; + blobUrl: string; + uncPath?: string; + url?: string; +} + +type FileHashType = 'sha256' | 'sha1'; + +interface FileDownloadDetails { + portalName: string; + downloadUrl: string; +} + +interface RoutingInfo { + intent?: string; + contentType?: string; + contentOrigin?: string; + productState?: string; + audience?: string; +} + +interface ReleaseFileInfo { + name?: string; + hash?: number[]; + sourceLocation?: FileLocation; + sizeInBytes?: number; + hashType?: FileHashType; + fileId?: any; + distributionRelativePath?: string; + partNumber?: string; + friendlyFileName?: string; + tenantFileLocationType?: string; + tenantFileLocation?: string; + signedEngineeringCopyLocation?: string; + encryptedDistributionBlobLocation?: string; + preEncryptedDistributionBlobLocation?: string; + secondaryDistributionHashRequired?: boolean; + secondaryDistributionHashType?: FileHashType; + lastModifiedAt?: Date; + cultureCodes?: string[]; + displayFileInDownloadCenter?: boolean; + isPrimaryFileInDownloadCenter?: boolean; + fileDownloadDetails?: FileDownloadDetails[]; +} + +interface UserInfo { + userPrincipalName?: string; +} + +interface OwnerInfo { + owner: UserInfo; +} + +interface ApproverInfo { + approver: UserInfo; + isAutoApproved: boolean; + isMandatory: boolean; +} + +interface AccessPermissionsInfo { + mainPublisher?: string; + releasePublishers?: string[]; + channelDownloadEntityDetails?: { [key: string]: string[] }; +} + +interface DownloadCenterLocaleInfo { + cultureCode?: string; + downloadTitle?: string; + shortName?: string; + shortDescription?: string; + longDescription?: string; + instructions?: string; + additionalInfo?: string; + keywords?: string[]; + version?: string; + relatedLinks?: { [key: string]: URL }; +} + +interface DownloadCenterInfo { + downloadCenterId: number; + publishToDownloadCenter?: boolean; + publishingGroup?: string; + operatingSystems?: string[]; + relatedReleases?: string[]; + kbNumbers?: string[]; + sbNumbers?: string[]; + locales?: DownloadCenterLocaleInfo[]; + additionalProperties?: { [key: string]: string }; +} + +interface ReleaseRequestMessage { + driEmail: string[]; + groupId?: string; + customerCorrelationId: string; + esrpCorrelationId: string; + contextData?: { [key: string]: string }; + releaseInfo: ReleaseInfo; + productInfo: ProductInfo; + files: ReleaseFileInfo[]; + routingInfo?: RoutingInfo; + createdBy: UserInfo; + owners: OwnerInfo[]; + approvers: ApproverInfo[]; + accessPermissionsInfo: AccessPermissionsInfo; + jwsToken?: string; + publisherId?: string; + downloadCenterInfo?: DownloadCenterInfo; +} + +function getCertificateBuffer(input: string) { + return Buffer.from(input.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\n/g, ''), 'base64'); +} + +function getThumbprint(input: string, algorithm: string): Buffer { + const buffer = getCertificateBuffer(input); + return crypto.createHash(algorithm).update(buffer).digest(); +} + +function getKeyFromPFX(pfx: string): string { + const pfxCertificatePath = path.join(os.tmpdir(), 'cert.pfx'); + const pemKeyPath = path.join(os.tmpdir(), 'key.pem'); + + try { + const pfxCertificate = Buffer.from(pfx, 'base64'); + fs.writeFileSync(pfxCertificatePath, pfxCertificate); + cp.execSync(`openssl pkcs12 -in "${pfxCertificatePath}" -nocerts -nodes -out "${pemKeyPath}" -passin pass:`); + const raw = fs.readFileSync(pemKeyPath, 'utf-8'); + const result = raw.match(/-----BEGIN PRIVATE KEY-----[\s\S]+?-----END PRIVATE KEY-----/g)![0]; + return result; + } finally { + fs.rmSync(pfxCertificatePath, { force: true }); + fs.rmSync(pemKeyPath, { force: true }); + } +} + +function getCertificatesFromPFX(pfx: string): string[] { + const pfxCertificatePath = path.join(os.tmpdir(), 'cert.pfx'); + const pemCertificatePath = path.join(os.tmpdir(), 'cert.pem'); + + try { + const pfxCertificate = Buffer.from(pfx, 'base64'); + fs.writeFileSync(pfxCertificatePath, pfxCertificate); + cp.execSync(`openssl pkcs12 -in "${pfxCertificatePath}" -nokeys -out "${pemCertificatePath}" -passin pass:`); + const raw = fs.readFileSync(pemCertificatePath, 'utf-8'); + const matches = raw.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g); + return matches ? matches.reverse() : []; + } finally { + fs.rmSync(pfxCertificatePath, { force: true }); + fs.rmSync(pemCertificatePath, { force: true }); + } +} + +class ESRPReleaseService { + + static async create( + log: (...args: any[]) => void, tenantId: string, clientId: string, - authCertSubjectName: string, - requestSigningCertSubjectName: string, + authCertificatePfx: string, + requestSigningCertificatePfx: string, + containerClient: ContainerClient ) { - this.authPath = this.tmp.tmpNameSync(); - fs.writeFileSync(this.authPath, JSON.stringify({ - Version: '1.0.0', - AuthenticationType: 'AAD_CERT', - TenantId: tenantId, - ClientId: clientId, - AuthCert: { - SubjectName: authCertSubjectName, - StoreLocation: 'LocalMachine', - StoreName: 'My', - SendX5c: 'true' - }, - RequestSigningCert: { - SubjectName: requestSigningCertSubjectName, - StoreLocation: 'LocalMachine', - StoreName: 'My' + const authKey = getKeyFromPFX(authCertificatePfx); + const authCertificate = getCertificatesFromPFX(authCertificatePfx)[0]; + const requestSigningKey = getKeyFromPFX(requestSigningCertificatePfx); + const requestSigningCertificates = getCertificatesFromPFX(requestSigningCertificatePfx); + + const app = new ConfidentialClientApplication({ + auth: { + clientId, + authority: `https://login.microsoftonline.com/${tenantId}`, + clientCertificate: { + thumbprintSha256: getThumbprint(authCertificate, 'sha256').toString('hex'), + privateKey: authKey, + x5c: authCertificate + } } - })); + }); + + const response = await app.acquireTokenByClientCredential({ + scopes: ['https://api.esrp.microsoft.com/.default'] + }); + + return new ESRPReleaseService(log, clientId, response!.accessToken, requestSigningCertificates, requestSigningKey, containerClient); } - async release( - version: string, - filePath: string - ): Promise { - this.log(`Submitting release for ${version}: ${filePath}`); - const submitReleaseResult = await this.SubmitRelease(version, filePath); + private static API_URL = 'https://api.esrp.microsoft.com/api/v3/releaseservices/clients/'; - if (submitReleaseResult.submissionResponse.statusCode !== 'pass') { - throw new Error(`Unexpected status code: ${submitReleaseResult.submissionResponse.statusCode}`); - } + private constructor( + private readonly log: (...args: any[]) => void, + private readonly clientId: string, + private readonly accessToken: string, + private readonly requestSigningCertificates: string[], + private readonly requestSigningKey: string, + private readonly containerClient: ContainerClient + ) { } - const releaseId = submitReleaseResult.submissionResponse.operationId; - this.log(`Successfully submitted release ${releaseId}. Polling for completion...`); + async createRelease(version: string, filePath: string, friendlyFileName: string) { + const correlationId = crypto.randomUUID(); + const blobClient = this.containerClient.getBlockBlobClient(correlationId); - let details!: ReleaseDetailsResult; + this.log(`Uploading ${filePath} to ${blobClient.url}`); + await blobClient.uploadFile(filePath); + this.log('Uploaded blob successfully'); - // Poll every 5 seconds, wait 60 minutes max -> poll 60/5*60=720 times - for (let i = 0; i < 720; i++) { - details = await this.ReleaseDetails(releaseId); + try { + this.log(`Submitting release for ${version}: ${filePath}`); + const submitReleaseResult = await this.submitRelease(version, filePath, friendlyFileName, correlationId, blobClient); - if (details.releaseDetails[0].statusCode === 'pass') { - break; - } else if (details.releaseDetails[0].statusCode !== 'inprogress') { - throw new Error(`Failed to submit release: ${JSON.stringify(details)}`); + this.log(`Successfully submitted release ${submitReleaseResult.operationId}. Polling for completion...`); + + // Poll every 5 seconds, wait 60 minutes max -> poll 60/5*60=720 times + for (let i = 0; i < 720; i++) { + await new Promise(c => setTimeout(c, 5000)); + const releaseStatus = await this.getReleaseStatus(submitReleaseResult.operationId); + + if (releaseStatus.status === 'pass') { + break; + } else if (releaseStatus.status !== 'inprogress') { + throw new Error(`Failed to submit release: ${JSON.stringify(releaseStatus)}`); + } } - await new Promise(c => setTimeout(c, 5000)); + const releaseDetails = await this.getReleaseDetails(submitReleaseResult.operationId); + + if (releaseDetails.status !== 'pass') { + throw new Error(`Timed out waiting for release: ${JSON.stringify(releaseDetails)}`); + } + + this.log('Successfully created release:', releaseDetails.files[0].fileDownloadDetails![0].downloadUrl); + return releaseDetails.files[0].fileDownloadDetails![0].downloadUrl; + } finally { + this.log(`Deleting blob ${blobClient.url}`); + await blobClient.delete(); + this.log('Deleted blob successfully'); } - - if (details.releaseDetails[0].statusCode !== 'pass') { - throw new Error(`Timed out waiting for release ${releaseId}: ${JSON.stringify(details)}`); - } - - const fileId = details.releaseDetails[0].fileDetails[0].publisherKey; - this.log('Release completed successfully with fileId: ', fileId); - - return { releaseId, fileId }; } - private async SubmitRelease( + private async submitRelease( version: string, - filePath: string - ): Promise { - const policyPath = this.tmp.tmpNameSync(); - fs.writeFileSync(policyPath, JSON.stringify({ - Version: '1.0.0', - Audience: 'InternalLimited', - Intent: 'distribution', - ContentType: 'InstallPackage' - })); - - const inputPath = this.tmp.tmpNameSync(); + filePath: string, + friendlyFileName: string, + correlationId: string, + blobClient: BlobClient + ): Promise { const size = fs.statSync(filePath).size; - const istream = fs.createReadStream(filePath); - const sha256 = await hashStream('sha256', istream); - fs.writeFileSync(inputPath, JSON.stringify({ - Version: '1.0.0', - ReleaseInfo: { - ReleaseMetadata: { - Title: 'VS Code', - Properties: { - ReleaseContentType: 'InstallPackage' - }, - MinimumNumberOfApprovers: 1 + const hash = await hashStream('sha256', fs.createReadStream(filePath)); + + const message: ReleaseRequestMessage = { + customerCorrelationId: correlationId, + esrpCorrelationId: correlationId, + driEmail: ['joao.moreno@microsoft.com'], + createdBy: { userPrincipalName: 'jomo@microsoft.com' }, + owners: [{ owner: { userPrincipalName: 'jomo@microsoft.com' } }], + approvers: [{ approver: { userPrincipalName: 'jomo@microsoft.com' }, isAutoApproved: true, isMandatory: false }], + releaseInfo: { + title: 'VS Code', + properties: { + 'ReleaseContentType': 'InstallPackage' }, - ProductInfo: { - Name: 'VS Code', - Version: version, - Description: path.basename(filePath, path.extname(filePath)), - }, - Owners: [ - { - Owner: { - UserPrincipalName: 'jomo@microsoft.com' - } - } - ], - Approvers: [ - { - Approver: { - UserPrincipalName: 'jomo@microsoft.com' - }, - IsAutoApproved: true, - IsMandatory: false - } - ], - AccessPermissions: { - MainPublisher: 'VSCode', - ChannelDownloadEntityDetails: { - Consumer: ['VSCode'] - } - }, - CreatedBy: { - UserPrincipalName: 'jomo@microsoft.com' + minimumNumberOfApprovers: 1 + }, + productInfo: { + name: 'VS Code', + version, + description: 'VS Code' + }, + accessPermissionsInfo: { + mainPublisher: 'VSCode', + channelDownloadEntityDetails: { + AllDownloadEntities: ['VSCode'] } }, - ReleaseBatches: [ - { - ReleaseRequestFiles: [ - { - SizeInBytes: size, - SourceHash: sha256, - HashType: 'SHA256', - SourceLocation: path.basename(filePath) - } - ], - SourceLocationType: 'UNC', - SourceRootDirectory: path.dirname(filePath), - DestinationLocationType: 'AzureBlob' - } - ] - })); + routingInfo: { + intent: 'filedownloadlinkgeneration' + }, + files: [{ + name: path.basename(filePath), + friendlyFileName, + tenantFileLocation: blobClient.url, + tenantFileLocationType: 'AzureBlob', + sourceLocation: { + type: 'azureBlob', + blobUrl: blobClient.url + }, + hashType: 'sha256', + hash: Array.from(hash), + sizeInBytes: size + }] + }; - const outputPath = this.tmp.tmpNameSync(); - cp.execSync(`ESRPClient SubmitRelease -a ${this.authPath} -p ${policyPath} -i ${inputPath} -o ${outputPath}`, { stdio: 'inherit' }); + message.jwsToken = await this.generateJwsToken(message); - const output = fs.readFileSync(outputPath, 'utf8'); - return JSON.parse(output) as SubmitReleaseResult; + const res = await fetch(`${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.accessToken}` + }, + body: JSON.stringify(message) + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to submit release: ${res.statusText}\n${text}`); + } + + return await res.json() as ReleaseSubmitResponse; } - private async ReleaseDetails( - releaseId: string - ): Promise { - const inputPath = this.tmp.tmpNameSync(); - fs.writeFileSync(inputPath, JSON.stringify({ - Version: '1.0.0', - OperationIds: [releaseId] - })); + private async getReleaseStatus(releaseId: string): Promise { + const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grs/${releaseId}`; - const outputPath = this.tmp.tmpNameSync(); - cp.execSync(`ESRPClient ReleaseDetails -a ${this.authPath} -i ${inputPath} -o ${outputPath}`, { stdio: 'inherit' }); + const res = await fetch(url, { + headers: { + 'Authorization': `Bearer ${this.accessToken}` + } + }); - const output = fs.readFileSync(outputPath, 'utf8'); - return JSON.parse(output) as ReleaseDetailsResult; - } -} + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to get release status: ${res.statusText}\n${text}`); + } -async function releaseAndProvision( - log: (...args: any[]) => void, - releaseTenantId: string, - releaseClientId: string, - releaseAuthCertSubjectName: string, - releaseRequestSigningCertSubjectName: string, - provisionTenantId: string, - provisionAADUsername: string, - provisionAADPassword: string, - version: string, - quality: string, - filePath: string -): Promise { - const fileName = `${quality}/${version}/${path.basename(filePath)}`; - const result = `${e('PRSS_CDN_URL')}/${fileName}`; - - const res = await retry(() => fetch(result)); - - if (res.status === 200) { - log(`Already released and provisioned: ${result}`); - return result; + return await res.json() as ReleaseResultMessage; } - const tmp = new Temp(); - process.on('exit', () => tmp.dispose()); + private async getReleaseDetails(releaseId: string): Promise { + const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grd/${releaseId}`; - const esrpclient = new ESRPClient(log, tmp, releaseTenantId, releaseClientId, releaseAuthCertSubjectName, releaseRequestSigningCertSubjectName); - const release = await esrpclient.release(version, filePath); + const res = await fetch(url, { + headers: { + 'Authorization': `Bearer ${this.accessToken}` + } + }); - const credential = new ClientSecretCredential(provisionTenantId, provisionAADUsername, provisionAADPassword); - const accessToken = await credential.getToken(['https://microsoft.onmicrosoft.com/DS.Provisioning.WebApi/.default']); - const service = new ProvisionService(log, accessToken.token); - await service.provision(release.releaseId, release.fileId, fileName); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to get release status: ${res.statusText}\n${text}`); + } - return result; + return await res.json() as ReleaseDetailsMessage; + } + + private async generateJwsToken(message: ReleaseRequestMessage): Promise { + return jws.sign({ + header: { + alg: 'RS256', + crit: ['exp', 'x5t'], + // Release service uses ticks, not seconds :roll_eyes: (https://stackoverflow.com/a/7968483) + exp: ((Date.now() + (6 * 60 * 1000)) * 10000) + 621355968000000000, + // Release service uses hex format, not base64url :roll_eyes: + x5t: getThumbprint(this.requestSigningCertificates[0], 'sha1').toString('hex'), + // Release service uses a '.' separated string, not an array of strings :roll_eyes: + x5c: this.requestSigningCertificates.map(c => getCertificateBuffer(c).toString('base64url')).join('.') as any, + }, + payload: message, + privateKey: this.requestSigningKey, + }); + } } class State { @@ -666,8 +788,10 @@ function getRealType(type: string) { } } -async function processArtifact(artifact: Artifact, artifactFilePath: string, cosmosDBAccessToken: string): Promise { - const log = (...args: any[]) => console.log(`[${artifact.name}]`, ...args); +async function processArtifact( + artifact: Artifact, + filePath: string +) { const match = /^vscode_(?[^_]+)_(?[^_]+)(?:_legacy)?_(?[^_]+)_(?[^_]+)$/.exec(artifact.name); if (!match) { @@ -675,38 +799,48 @@ async function processArtifact(artifact: Artifact, artifactFilePath: string, cos } // getPlatform needs the unprocessedType + const { cosmosDBAccessToken, blobServiceAccessToken } = JSON.parse(e('PUBLISH_AUTH_TOKENS')); const quality = e('VSCODE_QUALITY'); - const commit = e('BUILD_SOURCEVERSION'); + const version = e('BUILD_SOURCEVERSION'); const { product, os, arch, unprocessedType } = match.groups!; const isLegacy = artifact.name.includes('_legacy'); const platform = getPlatform(product, os, arch, unprocessedType, isLegacy); const type = getRealType(unprocessedType); - const size = fs.statSync(artifactFilePath).size; - const stream = fs.createReadStream(artifactFilePath); + const size = fs.statSync(filePath).size; + const stream = fs.createReadStream(filePath); const [hash, sha256hash] = await Promise.all([hashStream('sha1', stream), hashStream('sha256', stream)]); // CodeQL [SM04514] Using SHA1 only for legacy reasons, we are actually only respecting SHA256 - const url = await releaseAndProvision( + const log = (...args: any[]) => console.log(`[${artifact.name}]`, ...args); + const blobServiceClient = new BlobServiceClient(`https://${e('VSCODE_STAGING_BLOB_STORAGE_ACCOUNT_NAME')}.blob.core.windows.net/`, { getToken: async () => blobServiceAccessToken }); + const containerClient = blobServiceClient.getContainerClient('staging'); + + const releaseService = await ESRPReleaseService.create( log, e('RELEASE_TENANT_ID'), e('RELEASE_CLIENT_ID'), - e('RELEASE_AUTH_CERT_SUBJECT_NAME'), - e('RELEASE_REQUEST_SIGNING_CERT_SUBJECT_NAME'), - e('PROVISION_TENANT_ID'), - e('PROVISION_AAD_USERNAME'), - e('PROVISION_AAD_PASSWORD'), - commit, - quality, - artifactFilePath + e('RELEASE_AUTH_CERT'), + e('RELEASE_REQUEST_SIGNING_CERT'), + containerClient ); - const asset: Asset = { platform, type, url, hash, sha256hash, size, supportsFastUpdate: true }; + const friendlyFileName = `${quality}/${version}/${path.basename(filePath)}`; + const url = `${e('PRSS_CDN_URL')}/${friendlyFileName}`; + const res = await retry(() => fetch(url)); + + if (res.status === 200) { + log(`Already released and provisioned: ${url}`); + } else { + await releaseService.createRelease(version, filePath, friendlyFileName); + } + + const asset: Asset = { platform, type, url, hash: hash.toString('hex'), sha256hash: sha256hash.toString('hex'), size, supportsFastUpdate: true }; log('Creating asset...', JSON.stringify(asset, undefined, 2)); await retry(async (attempt) => { log(`Creating asset in Cosmos DB (attempt ${attempt})...`); - const client = new CosmosClient({ endpoint: e('AZURE_DOCUMENTDB_ENDPOINT')!, tokenProvider: () => Promise.resolve(`type=aad&ver=1.0&sig=${cosmosDBAccessToken}`) }); + const client = new CosmosClient({ endpoint: e('AZURE_DOCUMENTDB_ENDPOINT')!, tokenProvider: () => Promise.resolve(`type=aad&ver=1.0&sig=${cosmosDBAccessToken.token}`) }); const scripts = client.database('builds').container(quality).scripts; - await scripts.storedProcedure('createAsset').execute('', [commit, asset, true]); + await scripts.storedProcedure('createAsset').execute('', [version, asset, true]); }); log('Asset successfully created'); @@ -720,8 +854,8 @@ async function processArtifact(artifact: Artifact, artifactFilePath: string, cos // the CDN and finally update the build in Cosmos DB. async function main() { if (!isMainThread) { - const { artifact, artifactFilePath, cosmosDBAccessToken } = workerData; - await processArtifact(artifact, artifactFilePath, cosmosDBAccessToken); + const { artifact, artifactFilePath } = workerData; + await processArtifact(artifact, artifactFilePath); return; } @@ -742,7 +876,6 @@ async function main() { let resultPromise = Promise.resolve[]>([]); const operations: { name: string; operation: Promise }[] = []; - const cosmosDBAccessToken = await getAccessToken(e('AZURE_DOCUMENTDB_ENDPOINT')!, e('AZURE_TENANT_ID')!, e('AZURE_CLIENT_ID')!, e('AZURE_ID_TOKEN')!); while (true) { const [timeline, artifacts] = await Promise.all([retry(() => getPipelineTimeline()), retry(() => getPipelineArtifacts())]); @@ -784,7 +917,7 @@ async function main() { processing.add(artifact.name); const promise = new Promise((resolve, reject) => { - const worker = new Worker(__filename, { workerData: { artifact, artifactFilePath, cosmosDBAccessToken } }); + const worker = new Worker(__filename, { workerData: { artifact, artifactFilePath } }); worker.on('error', reject); worker.on('exit', code => { if (code === 0) { diff --git a/build/azure-pipelines/common/sign-win32.js b/build/azure-pipelines/common/sign-win32.js index da899cd3fc0..aa197bb1198 100644 --- a/build/azure-pipelines/common/sign-win32.js +++ b/build/azure-pipelines/common/sign-win32.js @@ -9,9 +9,6 @@ const path = require("path"); (0, sign_1.main)([ process.env['EsrpCliDllPath'], 'sign-windows', - process.env['ESRPPKI'], - process.env['ESRPAADUsername'], - process.env['ESRPAADPassword'], path.dirname(process.argv[2]), path.basename(process.argv[2]) ]); diff --git a/build/azure-pipelines/common/sign-win32.ts b/build/azure-pipelines/common/sign-win32.ts index 76828b42e1e..c2f3dbda151 100644 --- a/build/azure-pipelines/common/sign-win32.ts +++ b/build/azure-pipelines/common/sign-win32.ts @@ -9,9 +9,6 @@ import * as path from 'path'; main([ process.env['EsrpCliDllPath']!, 'sign-windows', - process.env['ESRPPKI']!, - process.env['ESRPAADUsername']!, - process.env['ESRPAADPassword']!, path.dirname(process.argv[2]), path.basename(process.argv[2]) ]); diff --git a/build/azure-pipelines/common/sign.js b/build/azure-pipelines/common/sign.js index 32996a7db03..f944e1adff8 100644 --- a/build/azure-pipelines/common/sign.js +++ b/build/azure-pipelines/common/sign.js @@ -109,33 +109,34 @@ function getParams(type) { throw new Error(`Sign type ${type} not found`); } } -function main([esrpCliPath, type, cert, username, password, folderPath, pattern]) { +function main([esrpCliPath, type, folderPath, pattern]) { const tmp = new Temp(); process.on('exit', () => tmp.dispose()); const patternPath = tmp.tmpNameSync(); fs.writeFileSync(patternPath, pattern); const paramsPath = tmp.tmpNameSync(); fs.writeFileSync(paramsPath, JSON.stringify(getParams(type))); - const keyFile = tmp.tmpNameSync(); - const key = crypto.randomBytes(32); - const iv = crypto.randomBytes(16); - fs.writeFileSync(keyFile, JSON.stringify({ key: key.toString('hex'), iv: iv.toString('hex') })); - const clientkeyPath = tmp.tmpNameSync(); - const clientkeyCypher = crypto.createCipheriv('aes-256-cbc', key, iv); - let clientkey = clientkeyCypher.update(password, 'utf8', 'hex'); - clientkey += clientkeyCypher.final('hex'); - fs.writeFileSync(clientkeyPath, clientkey); - const clientcertPath = tmp.tmpNameSync(); - const clientcertCypher = crypto.createCipheriv('aes-256-cbc', key, iv); - let clientcert = clientcertCypher.update(cert, 'utf8', 'hex'); - clientcert += clientcertCypher.final('hex'); - fs.writeFileSync(clientcertPath, clientcert); + const dotnetVersion = cp.execSync('dotnet --version', { encoding: 'utf8' }).trim(); + const adoTaskVersion = path.basename(path.dirname(path.dirname(esrpCliPath))); + const federatedTokenData = { + jobId: process.env['SYSTEM_JOBID'], + planId: process.env['SYSTEM_PLANID'], + projectId: process.env['SYSTEM_TEAMPROJECTID'], + hub: process.env['SYSTEM_HOSTTYPE'], + uri: process.env['SYSTEM_COLLECTIONURI'], + managedIdentityId: process.env['VSCODE_ESRP_CLIENT_ID'], + managedIdentityTenantId: process.env['VSCODE_ESRP_TENANT_ID'], + serviceConnectionId: process.env['VSCODE_ESRP_SERVICE_CONNECTION_ID'], + tempDirectory: os.tmpdir(), + systemAccessToken: process.env['SYSTEM_ACCESSTOKEN'] + }; const args = [ esrpCliPath, 'vsts.sign', - '-a', username, - '-k', clientkeyPath, - '-z', clientcertPath, + '-a', process.env['ESRP_CLIENT_ID'], + '-d', process.env['ESRP_TENANT_ID'], + '-k', JSON.stringify({ akv: 'vscode-esrp' }), + '-z', JSON.stringify({ akv: 'vscode-esrp', cert: 'esrp-sign' }), '-f', folderPath, '-p', patternPath, '-u', 'false', @@ -154,7 +155,14 @@ function main([esrpCliPath, type, cert, username, password, folderPath, pattern] '-i', 'https://www.microsoft.com', '-n', '5', '-r', 'true', - '-e', keyFile, + '-w', dotnetVersion, + '-skipAdoReportAttachment', 'false', + '-pendingAnalysisWaitTimeoutMinutes', '5', + '-adoTaskVersion', adoTaskVersion, + '-resourceUri', 'https://msazurecloud.onmicrosoft.com/api.esrp.microsoft.com', + '-esrpClientId', process.env['ESRP_CLIENT_ID'], + '-useMSIAuthentication', 'true', + '-federatedTokenData', JSON.stringify(federatedTokenData) ]; try { cp.execFileSync('dotnet', args, { stdio: 'inherit' }); diff --git a/build/azure-pipelines/common/sign.ts b/build/azure-pipelines/common/sign.ts index 28fca31205e..184ea5b3342 100644 --- a/build/azure-pipelines/common/sign.ts +++ b/build/azure-pipelines/common/sign.ts @@ -120,7 +120,7 @@ function getParams(type: string): Params[] { } } -export function main([esrpCliPath, type, cert, username, password, folderPath, pattern]: string[]) { +export function main([esrpCliPath, type, folderPath, pattern]: string[]) { const tmp = new Temp(); process.on('exit', () => tmp.dispose()); @@ -130,29 +130,29 @@ export function main([esrpCliPath, type, cert, username, password, folderPath, p const paramsPath = tmp.tmpNameSync(); fs.writeFileSync(paramsPath, JSON.stringify(getParams(type))); - const keyFile = tmp.tmpNameSync(); - const key = crypto.randomBytes(32); - const iv = crypto.randomBytes(16); - fs.writeFileSync(keyFile, JSON.stringify({ key: key.toString('hex'), iv: iv.toString('hex') })); + const dotnetVersion = cp.execSync('dotnet --version', { encoding: 'utf8' }).trim(); + const adoTaskVersion = path.basename(path.dirname(path.dirname(esrpCliPath))); - const clientkeyPath = tmp.tmpNameSync(); - const clientkeyCypher = crypto.createCipheriv('aes-256-cbc', key, iv); - let clientkey = clientkeyCypher.update(password, 'utf8', 'hex'); - clientkey += clientkeyCypher.final('hex'); - fs.writeFileSync(clientkeyPath, clientkey); - - const clientcertPath = tmp.tmpNameSync(); - const clientcertCypher = crypto.createCipheriv('aes-256-cbc', key, iv); - let clientcert = clientcertCypher.update(cert, 'utf8', 'hex'); - clientcert += clientcertCypher.final('hex'); - fs.writeFileSync(clientcertPath, clientcert); + const federatedTokenData = { + jobId: process.env['SYSTEM_JOBID'], + planId: process.env['SYSTEM_PLANID'], + projectId: process.env['SYSTEM_TEAMPROJECTID'], + hub: process.env['SYSTEM_HOSTTYPE'], + uri: process.env['SYSTEM_COLLECTIONURI'], + managedIdentityId: process.env['VSCODE_ESRP_CLIENT_ID'], + managedIdentityTenantId: process.env['VSCODE_ESRP_TENANT_ID'], + serviceConnectionId: process.env['VSCODE_ESRP_SERVICE_CONNECTION_ID'], + tempDirectory: os.tmpdir(), + systemAccessToken: process.env['SYSTEM_ACCESSTOKEN'] + }; const args = [ esrpCliPath, 'vsts.sign', - '-a', username, - '-k', clientkeyPath, - '-z', clientcertPath, + '-a', process.env['ESRP_CLIENT_ID']!, + '-d', process.env['ESRP_TENANT_ID']!, + '-k', JSON.stringify({ akv: 'vscode-esrp' }), + '-z', JSON.stringify({ akv: 'vscode-esrp', cert: 'esrp-sign' }), '-f', folderPath, '-p', patternPath, '-u', 'false', @@ -171,7 +171,14 @@ export function main([esrpCliPath, type, cert, username, password, folderPath, p '-i', 'https://www.microsoft.com', '-n', '5', '-r', 'true', - '-e', keyFile, + '-w', dotnetVersion, + '-skipAdoReportAttachment', 'false', + '-pendingAnalysisWaitTimeoutMinutes', '5', + '-adoTaskVersion', adoTaskVersion, + '-resourceUri', 'https://msazurecloud.onmicrosoft.com/api.esrp.microsoft.com', + '-esrpClientId', process.env['ESRP_CLIENT_ID']!, + '-useMSIAuthentication', 'true', + '-federatedTokenData', JSON.stringify(federatedTokenData) ]; try { diff --git a/build/azure-pipelines/darwin/product-build-darwin-sign.yml b/build/azure-pipelines/darwin/product-build-darwin-sign.yml index ccfbf58aa0b..8ae5c217db8 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-sign.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-sign.yml @@ -9,25 +9,30 @@ steps: inputs: version: 6.x - - task: EsrpClientTool@1 - continueOnError: true - displayName: Download ESRPClient - - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get ESRP Secrets" + - task: EsrpCodeSigning@5 inputs: - azureSubscription: vscode-esrp - KeyVaultName: vscode-esrp - SecretsFilter: "esrp-sign-legacy,esrp-aad-username,esrp-aad-password" + UseMSIAuthentication: true + ConnectedServiceName: vscode-esrp + AppRegistrationClientId: $(ESRP_CLIENT_ID) + AppRegistrationTenantId: $(ESRP_TENANT_ID) + AuthAKVName: vscode-esrp + AuthSignCertName: esrp-sign + FolderPath: . + Pattern: noop + displayName: 'Install ESRP Tooling' - download: current artifact: unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive displayName: Download $(VSCODE_ARCH) artifact - - script: node build/azure-pipelines/common/sign $(Agent.ToolsDirectory)/esrpclient/*/*/net6.0/esrpcli.dll sign-darwin $(esrp-sign-legacy) $(esrp-aad-username) $(esrp-aad-password) $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive VSCode-darwin-$(VSCODE_ARCH).zip + - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll sign-darwin $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive VSCode-darwin-$(VSCODE_ARCH).zip + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Codesign - - script: node build/azure-pipelines/common/sign $(Agent.ToolsDirectory)/esrpclient/*/*/net6.0/esrpcli.dll notarize-darwin $(esrp-sign-legacy) $(esrp-aad-username) $(esrp-aad-password) $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive VSCode-darwin-$(VSCODE_ARCH).zip + - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll notarize-darwin $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive VSCode-darwin-$(VSCODE_ARCH).zip + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Notarize - script: unzip $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-$(VSCODE_ARCH).zip -d $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH) diff --git a/build/azure-pipelines/linux/product-build-linux.yml b/build/azure-pipelines/linux/product-build-linux.yml index 8d93d7d8adf..b87a82b9fb3 100644 --- a/build/azure-pipelines/linux/product-build-linux.yml +++ b/build/azure-pipelines/linux/product-build-linux.yml @@ -34,13 +34,6 @@ steps: KeyVaultName: vscode-build-secrets SecretsFilter: "github-distro-mixin-password" - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get ESRP Secrets" - inputs: - azureSubscription: vscode-esrp - KeyVaultName: vscode-esrp - SecretsFilter: "esrp-sign-legacy,esrp-aad-username,esrp-aad-password" - - task: DownloadPipelineArtifact@2 inputs: artifact: Compilation @@ -356,14 +349,26 @@ steps: inputs: version: 6.x - - task: EsrpClientTool@1 - continueOnError: true - displayName: Download ESRPClient + - task: EsrpCodeSigning@5 + inputs: + UseMSIAuthentication: true + ConnectedServiceName: vscode-esrp + AppRegistrationClientId: $(ESRP_CLIENT_ID) + AppRegistrationTenantId: $(ESRP_TENANT_ID) + AuthAKVName: vscode-esrp + AuthSignCertName: esrp-sign + FolderPath: . + Pattern: noop + displayName: 'Install ESRP Tooling' - - script: node build/azure-pipelines/common/sign $(Agent.ToolsDirectory)/esrpclient/*/*/net6.0/esrpcli.dll sign-pgp $(esrp-sign-legacy) $(esrp-aad-username) $(esrp-aad-password) .build/linux/deb '*.deb' + - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll sign-pgp .build/linux/deb '*.deb' + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Codesign deb - - script: node build/azure-pipelines/common/sign $(Agent.ToolsDirectory)/esrpclient/*/*/net6.0/esrpcli.dll sign-pgp $(esrp-sign-legacy) $(esrp-aad-username) $(esrp-aad-password) .build/linux/rpm '*.rpm' + - script: node build/azure-pipelines/common/sign $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll sign-pgp .build/linux/rpm '*.rpm' + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Codesign rpm - script: echo "##vso[task.setvariable variable=ARTIFACT_PREFIX]attempt$(System.JobAttempt)_" diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index e52274ae1f2..d56ebc1cf12 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -134,14 +134,20 @@ variables: value: ${{ eq(parameters.VSCODE_STEP_ON_IT, true) }} - name: VSCODE_BUILD_MACOS_UNIVERSAL value: ${{ and(eq(parameters.VSCODE_BUILD_MACOS, true), eq(parameters.VSCODE_BUILD_MACOS_ARM64, true), eq(parameters.VSCODE_BUILD_MACOS_UNIVERSAL, true)) }} + - name: VSCODE_STAGING_BLOB_STORAGE_ACCOUNT_NAME + value: vscodeesrp - name: PRSS_CDN_URL value: https://vscode.download.prss.microsoft.com/dbazure/download - - name: PRSS_RELEASE_TENANT_ID + - name: VSCODE_ESRP_SERVICE_CONNECTION_ID + value: fe07e6ce-6ffb-4df9-8d27-d129523a3f3e + - name: VSCODE_ESRP_TENANT_ID value: 975f013f-7f24-47e8-a7d3-abc4752bf346 - - name: PRSS_RELEASE_CLIENT_ID + - name: VSCODE_ESRP_CLIENT_ID + value: 4ac7ed59-b5e9-4f66-9c30-8d1afa72d32d + - name: ESRP_TENANT_ID + value: 975f013f-7f24-47e8-a7d3-abc4752bf346 + - name: ESRP_CLIENT_ID value: c24324f7-e65f-4c45-8702-ed2d4c35df99 - - name: PRSS_PROVISION_TENANT_ID - value: 72f988bf-86f1-41af-91ab-2d7cd011db47 - name: AZURE_DOCUMENTDB_ENDPOINT value: https://vscode.documents.azure.com/ - name: VSCODE_MIXIN_REPO diff --git a/build/azure-pipelines/product-publish.yml b/build/azure-pipelines/product-publish.yml index a175cf9571c..c9728a2a113 100644 --- a/build/azure-pipelines/product-publish.yml +++ b/build/azure-pipelines/product-publish.yml @@ -5,9 +5,6 @@ steps: versionFilePath: .nvmrc nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - - task: SFP.build-tasks.esrpclient-tools-task.EsrpClientTool@2 - displayName: "Use EsrpClient" - - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get Secrets" inputs: @@ -20,7 +17,7 @@ steps: inputs: azureSubscription: vscode-esrp KeyVaultName: vscode-esrp - SecretsFilter: "esrp-auth,esrp-sign,esrp-aad-username,esrp-aad-password" + SecretsFilter: esrp-auth,esrp-sign # allow-any-unicode-next-line - pwsh: Write-Host "##vso[build.addbuildtag]🚀" @@ -65,22 +62,13 @@ steps: displayName: Create build if it hasn't been created before - pwsh: | - $ErrorActionPreference = "Stop" - $CertCollection = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection - $AuthCertBytes = [System.Convert]::FromBase64String("$(esrp-auth)") - $CertCollection.Import($AuthCertBytes, $null, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable -bxor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::PersistKeySet) - $RequestSigningCertIndex = $CertCollection.Count - $RequestSigningCertBytes = [System.Convert]::FromBase64String("$(esrp-sign)") - $CertCollection.Import($RequestSigningCertBytes, $null, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable -bxor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::PersistKeySet) - $CertStore = New-Object System.Security.Cryptography.X509Certificates.X509Store("My","LocalMachine") - $CertStore.Open("ReadWrite") - $CertStore.AddRange($CertCollection) - $CertStore.Close() - $AuthCertSubjectName = $CertCollection[0].Subject - $RequestSigningCertSubjectName = $CertCollection[$RequestSigningCertIndex].Subject - Write-Host "##vso[task.setvariable variable=RELEASE_AUTH_CERT_SUBJECT_NAME]$AuthCertSubjectName" - Write-Host "##vso[task.setvariable variable=RELEASE_REQUEST_SIGNING_CERT_SUBJECT_NAME]$RequestSigningCertSubjectName" - displayName: Import certificates + $publishAuthTokens = (node build/azure-pipelines/common/getPublishAuthTokens) + Write-Host "##vso[task.setvariable variable=PUBLISH_AUTH_TOKENS;issecret=true]$publishAuthTokens" + env: + AZURE_TENANT_ID: "$(AZURE_TENANT_ID)" + AZURE_CLIENT_ID: "$(AZURE_CLIENT_ID)" + AZURE_ID_TOKEN: "$(AZURE_ID_TOKEN)" + displayName: Get publish auth tokens - pwsh: node build/azure-pipelines/common/publish.js env: @@ -89,13 +77,11 @@ steps: AZURE_CLIENT_ID: "$(AZURE_CLIENT_ID)" AZURE_ID_TOKEN: "$(AZURE_ID_TOKEN)" SYSTEM_ACCESSTOKEN: $(System.AccessToken) - RELEASE_TENANT_ID: "$(PRSS_RELEASE_TENANT_ID)" - RELEASE_CLIENT_ID: "$(PRSS_RELEASE_CLIENT_ID)" - RELEASE_AUTH_CERT_SUBJECT_NAME: "$(RELEASE_AUTH_CERT_SUBJECT_NAME)" - RELEASE_REQUEST_SIGNING_CERT_SUBJECT_NAME: "$(RELEASE_REQUEST_SIGNING_CERT_SUBJECT_NAME)" - PROVISION_TENANT_ID: "$(PRSS_PROVISION_TENANT_ID)" - PROVISION_AAD_USERNAME: "$(esrp-aad-username)" - PROVISION_AAD_PASSWORD: "$(esrp-aad-password)" + PUBLISH_AUTH_TOKENS: "$(PUBLISH_AUTH_TOKENS)" + RELEASE_TENANT_ID: "$(ESRP_TENANT_ID)" + RELEASE_CLIENT_ID: "$(ESRP_CLIENT_ID)" + RELEASE_AUTH_CERT: "$(esrp-auth)" + RELEASE_REQUEST_SIGNING_CERT: "$(esrp-sign)" displayName: Process artifacts retryCountOnTaskFailure: 3 diff --git a/build/azure-pipelines/win32/product-build-win32.yml b/build/azure-pipelines/win32/product-build-win32.yml index a8be25fe7e8..de8b5b072b2 100644 --- a/build/azure-pipelines/win32/product-build-win32.yml +++ b/build/azure-pipelines/win32/product-build-win32.yml @@ -39,13 +39,6 @@ steps: KeyVaultName: vscode-build-secrets SecretsFilter: "github-distro-mixin-password" - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get ESRP Secrets" - inputs: - azureSubscription: vscode-esrp - KeyVaultName: vscode-esrp - SecretsFilter: "esrp-sign-legacy,esrp-aad-username,esrp-aad-password" - - task: DownloadPipelineArtifact@2 inputs: artifact: Compilation @@ -213,25 +206,35 @@ steps: inputs: version: 6.x - - task: EsrpClientTool@1 - displayName: Download ESRPClient + - task: EsrpCodeSigning@5 + inputs: + UseMSIAuthentication: true + ConnectedServiceName: vscode-esrp + AppRegistrationClientId: $(ESRP_CLIENT_ID) + AppRegistrationTenantId: $(ESRP_TENANT_ID) + AuthAKVName: vscode-esrp + AuthSignCertName: esrp-sign + FolderPath: . + Pattern: noop + displayName: 'Install ESRP Tooling' - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" - $EsrpClientTool = (gci -directory -filter EsrpClientTool_* $(Agent.RootDirectory)\_tasks | Select-Object -last 1).FullName - $EsrpCliZip = (gci -recurse -filter esrpcli.*.zip $EsrpClientTool | Select-Object -last 1).FullName - mkdir -p $(Agent.TempDirectory)\esrpcli - Expand-Archive -Path $EsrpCliZip -DestinationPath $(Agent.TempDirectory)\esrpcli - $EsrpCliDllPath = (gci -recurse -filter esrpcli.dll $(Agent.TempDirectory)\esrpcli | Select-Object -last 1).FullName - echo "##vso[task.setvariable variable=EsrpCliDllPath]$EsrpCliDllPath" + $EsrpCodeSigningTool = (gci -directory -filter EsrpCodeSigning_* $(Agent.RootDirectory)\_tasks | Select-Object -last 1).FullName + $Version = (gci -directory $EsrpCodeSigningTool | Select-Object -last 1).FullName + echo "##vso[task.setvariable variable=EsrpCliDllPath]$Version\net6.0\esrpcli.dll" displayName: Find ESRP CLI - - powershell: node build\azure-pipelines\common\sign $env:EsrpCliDllPath sign-windows $(esrp-sign-legacy) $(esrp-aad-username) $(esrp-aad-password) $(CodeSigningFolderPath) '*.dll,*.exe,*.node' + - powershell: node build\azure-pipelines\common\sign $env:EsrpCliDllPath sign-windows $(CodeSigningFolderPath) '*.dll,*.exe,*.node' + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Codesign executables and shared libraries - ${{ if eq(parameters.VSCODE_QUALITY, 'insider') }}: - - powershell: node build\azure-pipelines\common\sign $env:EsrpCliDllPath sign-windows-appx $(esrp-sign-legacy) $(esrp-aad-username) $(esrp-aad-password) $(CodeSigningFolderPath) '*.appx' + - powershell: node build\azure-pipelines\common\sign $env:EsrpCliDllPath sign-windows-appx $(CodeSigningFolderPath) '*.appx' + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Codesign context menu appx package - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: @@ -275,25 +278,23 @@ steps: - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" - $env:ESRPPKI = "$(esrp-sign-legacy)" - $env:ESRPAADUsername = "$(esrp-aad-username)" - $env:ESRPAADPassword = "$(esrp-aad-password)" exec { npm run -- gulp "vscode-win32-$(VSCODE_ARCH)-system-setup" --sign } $SetupPath = ".build\win32-$(VSCODE_ARCH)\system-setup\VSCodeSetup-$(VSCODE_ARCH)-$(VSCODE_VERSION).exe" mv .build\win32-$(VSCODE_ARCH)\system-setup\VSCodeSetup.exe $SetupPath echo "##vso[task.setvariable variable=SYSTEM_SETUP_PATH]$SetupPath" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Build system setup - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" - $env:ESRPPKI = "$(esrp-sign-legacy)" - $env:ESRPAADUsername = "$(esrp-aad-username)" - $env:ESRPAADPassword = "$(esrp-aad-password)" exec { npm run -- gulp "vscode-win32-$(VSCODE_ARCH)-user-setup" --sign } $SetupPath = ".build\win32-$(VSCODE_ARCH)\user-setup\VSCodeUserSetup-$(VSCODE_ARCH)-$(VSCODE_VERSION).exe" mv .build\win32-$(VSCODE_ARCH)\user-setup\VSCodeSetup.exe $SetupPath echo "##vso[task.setvariable variable=USER_SETUP_PATH]$SetupPath" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Build user setup - powershell: echo "##vso[task.setvariable variable=ARTIFACT_PREFIX]attempt$(System.JobAttempt)_" diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 4d7f8de8560..bc6e80cdb96 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -29acb63bb116a08e97797042505d48eecfa396f5d84a12114573aa70acaa48ec *chromedriver-v32.2.1-darwin-arm64.zip -a1fd00f8634c6b4d9e28ce8ac69684ea24f5274c9f17c0e39bd149b34568b84b *chromedriver-v32.2.1-darwin-x64.zip -6b311318f5a537e21d2d832609ce8306b4806e4c62aaa132ee87e063d45f5b00 *chromedriver-v32.2.1-linux-arm64.zip -ac1529a8f6e4c77fdae3bc92bc5bfcb40c3b19def0772de9d1874da7223517b7 *chromedriver-v32.2.1-linux-armv7l.zip -2329d1307729c714bef71d9f8250ed510b5a1ae07beefddee2371af70f712297 *chromedriver-v32.2.1-linux-x64.zip -84566e08029ea9b3d939f2329332b6b6d0c4a886f2aa2f2f53818b90af16a717 *chromedriver-v32.2.1-mas-arm64.zip -71c6e443617b6dd9b9962ff566ac7b8856db0a2e81b8b6ee7f985ffc96bb409a *chromedriver-v32.2.1-mas-x64.zip -0340ecc564b68a1632ea76f7e77fc06a4f150ea2fbb3c599c0dc8d78499c39e3 *chromedriver-v32.2.1-win32-arm64.zip -9d6d1a0b4863a4de2587e746b1a25da698076eda9268ef70ca24d43b39514859 *chromedriver-v32.2.1-win32-ia32.zip -1dc504383f63b2f178b902de41ba0efa28650bde54c3b2ebeee827c87a2768f3 *chromedriver-v32.2.1-win32-x64.zip -ba8e9ac663cc2edea61e7ddf12af835bf6ebb02b8d4ae6362c1f39c2390e7d22 *electron-api.json -a3544e9894f1ca544b0c8231f7c34f90a29f0ce3fd7853d592d51eb4ad4b31c5 *electron-v32.2.1-darwin-arm64-dsym-snapshot.zip -89377cde729f99707cb822e88999cfc312c4b82495600f38d13593c3de1b47f4 *electron-v32.2.1-darwin-arm64-dsym.zip -4e13b04efd03c237c3421b551180bc2b8dc6c35d49acd475e42c11aaa6b199aa *electron-v32.2.1-darwin-arm64-symbols.zip -906fbf9e7a5ee6d49ea107fdfd0e98bc80884fbf1f6ff38d824453f58c6ec259 *electron-v32.2.1-darwin-arm64.zip -fb3e5eb15915b4328820ebaf2c4a056f4ac374eb8e24479bdfd6f0cf8e1da1be *electron-v32.2.1-darwin-x64-dsym-snapshot.zip -0a95df2a44e0a42b9076e58d7e539e91ba7e583de77a8e94695d9c6dd03f201a *electron-v32.2.1-darwin-x64-dsym.zip -4864122e38f423f6ff9a8625696f323e908e613ebdab8ed7d40b374d6f9dec13 *electron-v32.2.1-darwin-x64-symbols.zip -56e2e4252b4d4e92075345f0b9dbefc8db49bdc6a4c45a87000f3cc705057907 *electron-v32.2.1-darwin-x64.zip -692aaf464bdb7bd7538e6392885571ef4d5f4d02319f84b99ada1827fbdfabf9 *electron-v32.2.1-linux-arm64-debug.zip -86161e2f6b1ca5cd6eb998863798186d9be270535d6912075001588e3e35e90d *electron-v32.2.1-linux-arm64-symbols.zip -6500fdbff988e0cda909643ba8439660a207c9a2d393fa63f680a0337e530342 *electron-v32.2.1-linux-arm64.zip -692aaf464bdb7bd7538e6392885571ef4d5f4d02319f84b99ada1827fbdfabf9 *electron-v32.2.1-linux-armv7l-debug.zip -cfe4cfb7a6818902b5cc1b493ec2f7a9e4dc8fcb63346ddf75bec3496658a363 *electron-v32.2.1-linux-armv7l-symbols.zip -7ffcce19ebdb30a9db78671c7f222edde66181a37c895834682d224e459200fc *electron-v32.2.1-linux-armv7l.zip -1e0318a7d125ebe015a5d4f214d186cd10e36021cc8555d376d8fda15a28a5ac *electron-v32.2.1-linux-x64-debug.zip -9d857cd5bdc81abb965e2e1bb73af8de31ef74cd182de52160b7afe805837574 *electron-v32.2.1-linux-x64-symbols.zip -4fc58e6e79e5b5793ec9b5d35c8926fcad5352b6a1b21b3edf42343487c90185 *electron-v32.2.1-linux-x64.zip -5fba9ea6c0d49ecd8bbbc87a9da6f860b901892e7ea487013e353bc2e951fbde *electron-v32.2.1-mas-arm64-dsym-snapshot.zip -71c2cdc23e61b7f13bda837fb9dfb5fdb9c6ca4fa755f2596f70874caaeeacea *electron-v32.2.1-mas-arm64-dsym.zip -448df71d1e62ca570b3f8b7d35b21eaa2870ce4877f12465cf6e54e90a16ac12 *electron-v32.2.1-mas-arm64-symbols.zip -4c53ef19385ab5a0040e6eda3a8f88f42b5f53de0e9a6118333613a1388fc39f *electron-v32.2.1-mas-arm64.zip -cbc5b08014cda37d6943f8a388ba1386f5ee1af3ca7a5ed28c12bf5fbd00f633 *electron-v32.2.1-mas-x64-dsym-snapshot.zip -a6bfe31ea9cef19794418d169872d5b68130b49989d7e2ee3d83a2853d4e706c *electron-v32.2.1-mas-x64-dsym.zip -a9ce94d21c61d3cf9f8319ae394c779058bc2377916e0330f0447e8c79b5b0e1 *electron-v32.2.1-mas-x64-symbols.zip -8b57ced11b88fa80f9a986662658cf4cb40a1138811ad6129fc826988b31f9ab *electron-v32.2.1-mas-x64.zip -5fcb399829066859399e8e3e7c5574b2e8885f632661fc2830da02be3d5803d2 *electron-v32.2.1-win32-arm64-pdb.zip -eb16ad799a8db120b1e4b13533f9f52e844b6252308ea9e182f290c7657a5361 *electron-v32.2.1-win32-arm64-symbols.zip -48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.1-win32-arm64-toolchain-profile.zip -3ac484f124c2012c0bff7640e82bed268876de1e3c6776716b5883d2de043a4f *electron-v32.2.1-win32-arm64.zip -75b6117bd0462641d93de9e0a7aac9c6a1a052c688f59426a66aafe34c7bb914 *electron-v32.2.1-win32-ia32-pdb.zip -10d4b64e7d2abeef1c93f2ec58d55715462229d20f61edf50bf5ceb4fce5719b *electron-v32.2.1-win32-ia32-symbols.zip -48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.1-win32-ia32-toolchain-profile.zip -e5d4e2b10e5215b8a7133cc3fa39875ea18e8d4ea41f9ba9a9ae9f13a4090f53 *electron-v32.2.1-win32-ia32.zip -83055f775e93c0be5c17a4312a552d3d0abb86a36b354f30973917a44d7a5656 *electron-v32.2.1-win32-x64-pdb.zip -38158fd465eb41674767707bfbd87ec67874aac9bd42c550aad6901035884697 *electron-v32.2.1-win32-x64-symbols.zip -48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.1-win32-x64-toolchain-profile.zip -494282c481eca93e1ee1d3e0df65ba0da5cec09b0c15bcc81521eee108839190 *electron-v32.2.1-win32-x64.zip -27050115afac161a368be0b92e842f65d5c7021b5b508b71ad972ce252bbbb3f *electron.d.ts -d8c054da57903f4e3297edd4de69177e9556feca9f2fc71b833608a486a7cae2 *ffmpeg-v32.2.1-darwin-arm64.zip -ac22a993719b804b560ed73ff1ad339df3eb126eeb9f5d496174a293ba952d78 *ffmpeg-v32.2.1-darwin-x64.zip -3f1eafaf4cd90ab43ba0267429189be182435849a166a2cbe1faefc0d07217c4 *ffmpeg-v32.2.1-linux-arm64.zip -3db919bc57e1a5bf7c1bae1d7aeacf4a331990ea82750391c0b24a046d9a2812 *ffmpeg-v32.2.1-linux-armv7l.zip -fe7d779dddbfb5da5999a7607fc5e3c7a6ab7c65e8da9fee1384918865231612 *ffmpeg-v32.2.1-linux-x64.zip -de4b05b040207d6807444f4289c0adc7f4947de0e32a0441073085cd76676648 *ffmpeg-v32.2.1-mas-arm64.zip -b007a2c582cd55727453fdf51ca3521d76f3ebeda8bfb3c2eeb56d56ec17a6a7 *ffmpeg-v32.2.1-mas-x64.zip -c72c467834669575ca1a5e34a624db71da3cbe63223f63d8f92bc4d2551a4164 *ffmpeg-v32.2.1-win32-arm64.zip -a9d26ba87262631ba279f6eae4164bcb289abe99c5a10e56c2e28e1e05b530bf *ffmpeg-v32.2.1-win32-ia32.zip -98c917caa3cd7ad10f2c48669c377a028d42673515034c05c3cac461213d5535 *ffmpeg-v32.2.1-win32-x64.zip -8b685975c9aeae9e5d8df85ad797492419e6414aa68a87d14f6fbb923d0f7dad *hunspell_dictionaries.zip -ee3871c7b533fc1c24baab89d25b60fc3e5f339b4c3e7767c768d833b0a828f4 *libcxx-objects-v32.2.1-linux-arm64.zip -3a01ecfc2f4e91bdc20280d8d3954347c0abd1bd53256e79a053d05f6a3ec664 *libcxx-objects-v32.2.1-linux-armv7l.zip -ab43146f8ca665a7064da6a82af2c7e3c3adce0788fb55862991f3a491bc692d *libcxx-objects-v32.2.1-linux-x64.zip -bb9dc46ad47b265fa353c42fc54ba584c2c890521069ea9de4c12cddb96297ea *libcxx_headers.zip -6846d928164a74dcad442da06cd79ecc788aa52815b8334e3a8a187f1650ce4f *libcxxabi_headers.zip -8f4cb6c9358c1bf9a0c81dcf94dd1f3683c42f3407441ed1ce074851bca0cbed *mksnapshot-v32.2.1-darwin-arm64.zip -ca0919eaa60722e8e864eeae331a571e10ecf02bb1bcd9028849436a15db4416 *mksnapshot-v32.2.1-darwin-x64.zip -1858861baadfd453eac7e78de2b1837253d4e44084aff22d8b6813602e0a3f4e *mksnapshot-v32.2.1-linux-arm64-x64.zip -15a713a85f2a3082c6b8943315bb7b9ab850f34a73544a619414f586e69b9b08 *mksnapshot-v32.2.1-linux-armv7l-x64.zip -5b82fba9a2ee305ef4f0818c406c726cfebc090b84fdaf39e76954f360740445 *mksnapshot-v32.2.1-linux-x64.zip -eef68fa0ea8ab11be45447e7e89fcac8dce3261d207807cd0d8366785964d7fc *mksnapshot-v32.2.1-mas-arm64.zip -26fcd020007a857611adad7ce7ba0b83b008edb130d87c93183c053b73f61a76 *mksnapshot-v32.2.1-mas-x64.zip -fbe7b665451fc4c48c4b09fe949374aedf95563b7d0b7f1927974b347205a8f9 *mksnapshot-v32.2.1-win32-arm64-x64.zip -3e0cc1d1cfa749a6364e2124b63583d7543c95c744c10400a6834726337468d0 *mksnapshot-v32.2.1-win32-ia32.zip -ef506867e5bb87c7e4380f9d9e93902d180a7425e1528acdd8283779e902f51e *mksnapshot-v32.2.1-win32-x64.zip +3dd66ff8058b2f42de3b34435eb00c3efec52a96397519b5b406859400a479f5 *chromedriver-v32.2.3-darwin-arm64.zip +b32a6da15cf24bc42cbfb54003d63317b7a5e6bbeb11f169c8734dc0988f0f4b *chromedriver-v32.2.3-darwin-x64.zip +13836465f2b59cc60b6493c7e55c3d96bac1a27be903aabaf27b5f88100c3aeb *chromedriver-v32.2.3-linux-arm64.zip +6f4933a7cb857b95ab8dda83e49679745d1d4b0753c680ad94919b88ad92469b *chromedriver-v32.2.3-linux-armv7l.zip +9fd34aee11d83693f2cffabb7fd8748bf05eddf4531677398d503f6be1d10102 *chromedriver-v32.2.3-linux-x64.zip +c1e7f22ffe54f07e849a06b62e4e540940760f1b58e3437517af971e1f61d733 *chromedriver-v32.2.3-mas-arm64.zip +4a79bbe0fab80ffdb132ddb710bffd3eaec26cb45945479a65725915d47da2dd *chromedriver-v32.2.3-mas-x64.zip +d29a542d87e84b3acef4d664c601771e4b6efce07123134cac80471397b7f062 *chromedriver-v32.2.3-win32-arm64.zip +da40875c612f69f3f072500f40ea5d356395ef0a046eaf56a0c790e44724386e *chromedriver-v32.2.3-win32-ia32.zip +8721a4ea9bf95ab37202e70b17f2bf8f43ca0f93560c34620276c15debacc732 *chromedriver-v32.2.3-win32-x64.zip +cd847cf0bd4dc8677755d6943f354540a4d7a5271f788d389c01d16a5bae47e8 *electron-api.json +438e4af8dd3c376228c34295ecdb144bb12caa51d855db621215afe975403603 *electron-v32.2.3-darwin-arm64-dsym-snapshot.zip +b678040bb9f18b7284434d3a5fb994fc8b4d87e467caa10b66e28f72412273b5 *electron-v32.2.3-darwin-arm64-dsym.zip +b8e14d9d964823c19bd69ceac044b2406c0b7f730c0a6757b02a6de4134e6c65 *electron-v32.2.3-darwin-arm64-symbols.zip +a7e57f17813cfff459f60982bd07328ebaf1728e2e9abc2d9e4debc185749f83 *electron-v32.2.3-darwin-arm64.zip +752f9ca100f8b2cdd6316783d509e52001f433cc79b6f2b66c67838bfe5b160a *electron-v32.2.3-darwin-x64-dsym-snapshot.zip +7a733751aed9fbbbea90394122a488a08e498e165f53f0d9cf284f19fc12da8d *electron-v32.2.3-darwin-x64-dsym.zip +7df6a51b10cf9d2f2d045d61fee05144a0db08495c89304eef41c07948254f8b *electron-v32.2.3-darwin-x64-symbols.zip +5e824c357281d85415c215de58ebe5c5daaa4fab2bc76e563d3da78655a7d57d *electron-v32.2.3-darwin-x64.zip +0f03c5002747c292388cacd0f95bfc01694cf0f54b63ec3224101eeae6b9896e *electron-v32.2.3-linux-arm64-debug.zip +e70af1c2c36c54b0ed912e9af52063ce1f6d856338f207eb297611eaccd1afcd *electron-v32.2.3-linux-arm64-symbols.zip +e435f44bad0f649f2a2b980197df3bfb581c5e78f41e4b5a8b7ca5bfb5d8e344 *electron-v32.2.3-linux-arm64.zip +0f03c5002747c292388cacd0f95bfc01694cf0f54b63ec3224101eeae6b9896e *electron-v32.2.3-linux-armv7l-debug.zip +49db1e2b7bf965af352d7fc41d74026a26ebabba443dafd71a8d7da8e7605690 *electron-v32.2.3-linux-armv7l-symbols.zip +5483ef724d5b363645e039b299c9480bff0e6ef4ad9d9f677675061e0c7010c4 *electron-v32.2.3-linux-armv7l.zip +72ccc7ebb4f46b034a6fdde0ba08b142141387c52086ab47ba647bdc82030ebb *electron-v32.2.3-linux-x64-debug.zip +08e209e9740c9460f8f584c7b3fe58e67e6b62652e3051d18babe950400760b2 *electron-v32.2.3-linux-x64-symbols.zip +f406b8c5f4a33da0c712f558a766348b26b8e2aac0e7bf945a00931161dc4229 *electron-v32.2.3-linux-x64.zip +2b7debd213c4eb73a47040a067346266a37d4c9cad4c25451dc53511658ad806 *electron-v32.2.3-mas-arm64-dsym-snapshot.zip +c603ff407f65233bfc58395e88d231743a96aaf6c27005e05826f6e02e5d124d *electron-v32.2.3-mas-arm64-dsym.zip +26e974578a3c0417e45cf64abc8edfd87a703e4122de5fdfd0473cea7bb3eddd *electron-v32.2.3-mas-arm64-symbols.zip +6427af76443900f5b1ba109ac83ab51bafae74166bb3cc8f17fd0ccba2fef9d4 *electron-v32.2.3-mas-arm64.zip +7f95d9b8ee3f6ee77adbd5c5ef154cafa29da9b4824990d2793e746943124252 *electron-v32.2.3-mas-x64-dsym-snapshot.zip +452c6f1bd79c3149a6acbee953b61454975879aecee882fcec2b80516e624c86 *electron-v32.2.3-mas-x64-dsym.zip +1f35c9a4c88b8678b15716a52a5b436ceb873893414318d4e4b77554835aafdb *electron-v32.2.3-mas-x64-symbols.zip +2a7910eae161e8a19102f037f1356e0a269a3291852dc278f966ff782dc740db *electron-v32.2.3-mas-x64.zip +be1bdbb291c8cd81661947c3cba8529429aba9b919568c51b84dcf49b78b5505 *electron-v32.2.3-win32-arm64-pdb.zip +7b9d111e1d744c1a5383bed379f2c98f7ee1ef8e6dd2c825340388cfbe4f091d *electron-v32.2.3-win32-arm64-symbols.zip +48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.3-win32-arm64-toolchain-profile.zip +bda5da0bcc17cca0f8d58ab56a693aa9f95b513079380adf3509fe1484625fff *electron-v32.2.3-win32-arm64.zip +22d67116167fd021042623283e662c0290a370b0dc63e117e9ca90a3adad19c0 *electron-v32.2.3-win32-ia32-pdb.zip +6c032b28e246afdf36ceb1f648169ebc7b282756f3b3293949aa4e057236d3c3 *electron-v32.2.3-win32-ia32-symbols.zip +48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.3-win32-ia32-toolchain-profile.zip +2b38140726382b7fbcb8d24f7a40e45f7567c3987e9fd2397010603d7c1661fc *electron-v32.2.3-win32-ia32.zip +8ecf644906c7c65da1874a723388216e7dd1a7373326520fe8b040808c04fca5 *electron-v32.2.3-win32-x64-pdb.zip +56a5a17300a226118eab5e4bac2e79de26ae2d7e380862de93540ed2fb0c464a *electron-v32.2.3-win32-x64-symbols.zip +48b81d28fdceb4ab3ca27650d79bab910a1a19dbda72271882bfdc877c71975f *electron-v32.2.3-win32-x64-toolchain-profile.zip +16c996275a3bfc00ac18a4ce733c688fb7cf260a3a5c166514acd047cefe9b78 *electron-v32.2.3-win32-x64.zip +037ca881119c29a88425115b67c5feba8fba34f3b4fce72a225eeb3b55233518 *electron.d.ts +f321aad7cab379e52fd564c2726ef1bba833a7a42b49b74eb01971fe1f21c615 *ffmpeg-v32.2.3-darwin-arm64.zip +e4bda65b109fbc4b6575fdb610ddb0da2e5b6d45fab25cab04e83b858495599e *ffmpeg-v32.2.3-darwin-x64.zip +3f1eafaf4cd90ab43ba0267429189be182435849a166a2cbe1faefc0d07217c4 *ffmpeg-v32.2.3-linux-arm64.zip +3db919bc57e1a5bf7c1bae1d7aeacf4a331990ea82750391c0b24a046d9a2812 *ffmpeg-v32.2.3-linux-armv7l.zip +fe7d779dddbfb5da5999a7607fc5e3c7a6ab7c65e8da9fee1384918865231612 *ffmpeg-v32.2.3-linux-x64.zip +6721b25ed05b2bbc7e4dfa4acad278ca50673aeb33dd98aab1bfdd2cfb775c7f *ffmpeg-v32.2.3-mas-arm64.zip +0dbd22f17adcbcc9b61a129d1e5d2c61edbf8f9bcaeb21482ddb668ac5314f39 *ffmpeg-v32.2.3-mas-x64.zip +e60af8bc9ac21fc23219d2d7be3bb50a787cae24a8950cb06ff63eff470da41a *ffmpeg-v32.2.3-win32-arm64.zip +a952f14410d3dfd8e604467db93e186a9e79c48afdebcb9f70d4b8e6ab1cbc9f *ffmpeg-v32.2.3-win32-ia32.zip +07335b144f838654abbbaa05d12828c2379dde8ff92c41b702b83a859103c856 *ffmpeg-v32.2.3-win32-x64.zip +cbccfdbf9bdd62824622afaf1d03e587cfc3af15a17c0b0a6aee85dd8a8704cc *hunspell_dictionaries.zip +32b03e672adb2dd6839a53fccd7bf69221fe5d2460a4159b143f5da90f2a3eb6 *libcxx-objects-v32.2.3-linux-arm64.zip +bd307af290cebf9f89f7a13110e33f27f05c5a54e798f0651259944536cd1219 *libcxx-objects-v32.2.3-linux-armv7l.zip +71eee68df857411eebd60240c4314c0fab5e52e7d38d20d95a0e957569255c7d *libcxx-objects-v32.2.3-linux-x64.zip +d8e209919e726a30f0b1164c2973469f24e7927be32a8db37d51cf35a98f4a8a *libcxx_headers.zip +153994318f1bb933bb26c5b8f02294da9baa586affd7f3e6ddcc363d1b363722 *libcxxabi_headers.zip +30fd6c5205f18feafe47a9f0dbc75d5b0bb964dc9b3d744666410a67a3584b99 *mksnapshot-v32.2.3-darwin-arm64.zip +5986e9ddc4dc8ed073c5cd7cec3f9041e94f9d5647c344071db3665e5e23c0e6 *mksnapshot-v32.2.3-darwin-x64.zip +d5d94482a0d250c6c0714e33c0f21a03b0908007a97337a415f4bd8347dd5ae6 *mksnapshot-v32.2.3-linux-arm64-x64.zip +21abdb1b267edefee9320ea5bd5fd91758844add73ffd6b6a16fb7a760c464c7 *mksnapshot-v32.2.3-linux-armv7l-x64.zip +444dd1e5b56c1cee3f10be59ba9434aa6c7cbf21864f383f9f4c7f89b8bf7e8f *mksnapshot-v32.2.3-linux-x64.zip +0410382fa750f770d1e22cd299d47545ceab27cdc795d19da5b7a3b708d2eef3 *mksnapshot-v32.2.3-mas-arm64.zip +001c8cf8330f0e4f6c10e8dbcf9800b8a9a9f9a6e823a5514d3ad200d4d6b620 *mksnapshot-v32.2.3-mas-x64.zip +f661a64e235a02c7ba7daa812a063f2b8c4d251515bf9b18f8bc4bc256190c73 *mksnapshot-v32.2.3-win32-arm64-x64.zip +85c2eb6e3737511e367bf8492a612f5ef9f3107492aea5461c3acb7c48192264 *mksnapshot-v32.2.3-win32-ia32.zip +626aa98144e94c585d0b87196c0d9c5b84381b0e2b4bcb379e550836139f93f3 *mksnapshot-v32.2.3-win32-x64.zip diff --git a/build/darwin/create-universal-app.js b/build/darwin/create-universal-app.js index 1f53369356b..bced5a7166f 100644 --- a/build/darwin/create-universal-app.js +++ b/build/darwin/create-universal-app.js @@ -24,6 +24,8 @@ async function main(buildDir) { const filesToSkip = [ '**/CodeResources', '**/Credits.rtf', + // TODO: Should we consider expanding this to other files in this area? + '**/node_modules/@parcel/node-addon-api/nothing.target.mk' ]; await (0, vscode_universal_bundler_1.makeUniversalApp)({ x64AppPath, diff --git a/build/darwin/create-universal-app.ts b/build/darwin/create-universal-app.ts index 1f19053494d..e05f780b38d 100644 --- a/build/darwin/create-universal-app.ts +++ b/build/darwin/create-universal-app.ts @@ -28,6 +28,8 @@ async function main(buildDir?: string) { const filesToSkip = [ '**/CodeResources', '**/Credits.rtf', + // TODO: Should we consider expanding this to other files in this area? + '**/node_modules/@parcel/node-addon-api/nothing.target.mk' ]; await makeUniversalApp({ diff --git a/build/gulpfile.extensions.js b/build/gulpfile.extensions.js index 6b8ca04e809..13f27d6db47 100644 --- a/build/gulpfile.extensions.js +++ b/build/gulpfile.extensions.js @@ -230,28 +230,62 @@ exports.compileExtensionMediaBuildTask = compileExtensionMediaBuildTask; //#region Azure Pipelines +/** + * Cleans the build directory for extensions + */ const cleanExtensionsBuildTask = task.define('clean-extensions-build', util.rimraf('.build/extensions')); -const compileExtensionsBuildTask = task.define('compile-extensions-build', task.series( - cleanExtensionsBuildTask, - task.define('bundle-marketplace-extensions-build', () => ext.packageMarketplaceExtensionsStream(false).pipe(gulp.dest('.build'))), - task.define('bundle-extensions-build', () => ext.packageLocalExtensionsStream(false, false).pipe(gulp.dest('.build'))), -)); +exports.cleanExtensionsBuildTask = cleanExtensionsBuildTask; -gulp.task(compileExtensionsBuildTask); -gulp.task(task.define('extensions-ci', task.series(compileExtensionsBuildTask, compileExtensionMediaBuildTask))); +/** + * brings in the marketplace extensions for the build + */ +const bundleMarketplaceExtensionsBuildTask = task.define('bundle-marketplace-extensions-build', () => ext.packageMarketplaceExtensionsStream(false).pipe(gulp.dest('.build'))); + +/** + * Compiles the non-native extensions for the build + * @note this does not clean the directory ahead of it. See {@link cleanExtensionsBuildTask} for that. + */ +const compileNonNativeExtensionsBuildTask = task.define('compile-non-native-extensions-build', task.series( + bundleMarketplaceExtensionsBuildTask, + task.define('bundle-non-native-extensions-build', () => ext.packageNonNativeLocalExtensionsStream().pipe(gulp.dest('.build'))) +)); +gulp.task(compileNonNativeExtensionsBuildTask); +exports.compileNonNativeExtensionsBuildTask = compileNonNativeExtensionsBuildTask; + +/** + * Compiles the native extensions for the build + * @note this does not clean the directory ahead of it. See {@link cleanExtensionsBuildTask} for that. + */ +const compileNativeExtensionsBuildTask = task.define('compile-native-extensions-build', () => ext.packageNativeLocalExtensionsStream().pipe(gulp.dest('.build'))); +gulp.task(compileNativeExtensionsBuildTask); +exports.compileNativeExtensionsBuildTask = compileNativeExtensionsBuildTask; + +/** + * Compiles the extensions for the build. + * This is essentially a helper task that combines {@link cleanExtensionsBuildTask}, {@link compileNonNativeExtensionsBuildTask} and {@link compileNativeExtensionsBuildTask} + */ +const compileAllExtensionsBuildTask = task.define('compile-extensions-build', task.series( + cleanExtensionsBuildTask, + bundleMarketplaceExtensionsBuildTask, + task.define('bundle-extensions-build', () => ext.packageAllLocalExtensionsStream(false, false).pipe(gulp.dest('.build'))), +)); +gulp.task(compileAllExtensionsBuildTask); +exports.compileAllExtensionsBuildTask = compileAllExtensionsBuildTask; + +// This task is run in the compilation stage of the CI pipeline. We only compile the non-native extensions since those can be fully built regardless of platform. +// This defers the native extensions to the platform specific stage of the CI pipeline. +gulp.task(task.define('extensions-ci', task.series(compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask))); const compileExtensionsBuildPullRequestTask = task.define('compile-extensions-build-pr', task.series( cleanExtensionsBuildTask, - task.define('bundle-marketplace-extensions-build', () => ext.packageMarketplaceExtensionsStream(false).pipe(gulp.dest('.build'))), - task.define('bundle-extensions-build-pr', () => ext.packageLocalExtensionsStream(false, true).pipe(gulp.dest('.build'))), + bundleMarketplaceExtensionsBuildTask, + task.define('bundle-extensions-build-pr', () => ext.packageAllLocalExtensionsStream(false, true).pipe(gulp.dest('.build'))), )); - gulp.task(compileExtensionsBuildPullRequestTask); + +// This task is run in the compilation stage of the PR pipeline. We compile all extensions in it to verify compilation. gulp.task(task.define('extensions-ci-pr', task.series(compileExtensionsBuildPullRequestTask, compileExtensionMediaBuildTask))); - -exports.compileExtensionsBuildTask = compileExtensionsBuildTask; - //#endregion const compileWebExtensionsTask = task.define('compile-web', () => buildWebExtensions(false)); diff --git a/build/gulpfile.reh.js b/build/gulpfile.reh.js index 7ab8b138da8..4f00317173d 100644 --- a/build/gulpfile.reh.js +++ b/build/gulpfile.reh.js @@ -27,7 +27,7 @@ const File = require('vinyl'); const fs = require('fs'); const glob = require('glob'); const { compileBuildTask } = require('./gulpfile.compile'); -const { compileExtensionsBuildTask, compileExtensionMediaBuildTask } = require('./gulpfile.extensions'); +const { cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileExtensionMediaBuildTask } = require('./gulpfile.extensions'); const { vscodeWebResourceIncludes, createVSCodeWebFileContentMapper } = require('./gulpfile.vscode.web'); const cp = require('child_process'); const log = require('fancy-log'); @@ -468,6 +468,7 @@ function tweakProductForServerWeb(product) { const destinationFolderName = `vscode-${type}${dashed(platform)}${dashed(arch)}`; const serverTaskCI = task.define(`vscode-${type}${dashed(platform)}${dashed(arch)}${dashed(minified)}-ci`, task.series( + compileNativeExtensionsBuildTask, gulp.task(`node-${platform}-${arch}`), util.rimraf(path.join(BUILD_ROOT, destinationFolderName)), packageTask(type, platform, arch, sourceFolderName, destinationFolderName) @@ -476,7 +477,8 @@ function tweakProductForServerWeb(product) { const serverTask = task.define(`vscode-${type}${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( compileBuildTask, - compileExtensionsBuildTask, + cleanExtensionsBuildTask, + compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask, minified ? minifyTask : bundleTask, serverTaskCI diff --git a/build/gulpfile.vscode.js b/build/gulpfile.vscode.js index 5dc9437bc2c..030c39a861e 100644 --- a/build/gulpfile.vscode.js +++ b/build/gulpfile.vscode.js @@ -31,7 +31,7 @@ const { config } = require('./lib/electron'); const createAsar = require('./lib/asar').createAsar; const minimist = require('minimist'); const { compileBuildTask } = require('./gulpfile.compile'); -const { compileExtensionsBuildTask, compileExtensionMediaBuildTask } = require('./gulpfile.extensions'); +const { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask } = require('./gulpfile.extensions'); const { promisify } = require('util'); const glob = promisify(require('glob')); const rcedit = promisify(require('rcedit')); @@ -487,6 +487,7 @@ BUILD_TARGETS.forEach(buildTarget => { const destinationFolderName = `VSCode${dashed(platform)}${dashed(arch)}`; const tasks = [ + compileNativeExtensionsBuildTask, util.rimraf(path.join(buildRoot, destinationFolderName)), packageTask(platform, arch, sourceFolderName, destinationFolderName, opts) ]; @@ -500,7 +501,8 @@ BUILD_TARGETS.forEach(buildTarget => { const vscodeTask = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( compileBuildTask, - compileExtensionsBuildTask, + cleanExtensionsBuildTask, + compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask, minified ? minifyVSCodeTask : bundleVSCodeTask, vscodeTaskCI @@ -537,7 +539,7 @@ gulp.task(task.define( 'vscode-translations-export', task.series( core, - compileExtensionsBuildTask, + compileAllExtensionsBuildTask, function () { const pathToMetadata = './out-build/nls.metadata.json'; const pathToExtensions = '.build/extensions/*'; diff --git a/build/gulpfile.vscode.web.js b/build/gulpfile.vscode.web.js index 1b498cea837..02b17022fa8 100644 --- a/build/gulpfile.vscode.web.js +++ b/build/gulpfile.vscode.web.js @@ -202,7 +202,7 @@ function packageTask(sourceFolderName, destinationFolderName) { const compileWebExtensionsBuildTask = task.define('compile-web-extensions-build', task.series( task.define('clean-web-extensions-build', util.rimraf('.build/web/extensions')), - task.define('bundle-web-extensions-build', () => extensions.packageLocalExtensionsStream(true, false).pipe(gulp.dest('.build/web'))), + task.define('bundle-web-extensions-build', () => extensions.packageAllLocalExtensionsStream(true, false).pipe(gulp.dest('.build/web'))), task.define('bundle-marketplace-web-extensions-build', () => extensions.packageMarketplaceExtensionsStream(true).pipe(gulp.dest('.build/web'))), task.define('bundle-web-extension-media-build', () => extensions.buildExtensionMedia(false, '.build/web/extensions')), )); diff --git a/build/lib/extensions.js b/build/lib/extensions.js index 4e704bdab6f..8630c8fa061 100644 --- a/build/lib/extensions.js +++ b/build/lib/extensions.js @@ -6,7 +6,9 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.fromMarketplace = fromMarketplace; exports.fromGithub = fromGithub; -exports.packageLocalExtensionsStream = packageLocalExtensionsStream; +exports.packageNonNativeLocalExtensionsStream = packageNonNativeLocalExtensionsStream; +exports.packageNativeLocalExtensionsStream = packageNativeLocalExtensionsStream; +exports.packageAllLocalExtensionsStream = packageAllLocalExtensionsStream; exports.packageMarketplaceExtensionsStream = packageMarketplaceExtensionsStream; exports.scanBuiltinExtensions = scanBuiltinExtensions; exports.translatePackageJSON = translatePackageJSON; @@ -245,6 +247,13 @@ function fromGithub({ name, version, repo, sha256, metadata }) { .pipe(json({ __metadata: metadata })) .pipe(packageJsonFilter.restore); } +/** + * All extensions that are known to have some native component and thus must be built on the + * platform that is being built. + */ +const nativeExtensions = [ + 'microsoft-authentication', +]; const excludedExtensions = [ 'vscode-api-tests', 'vscode-colorize-tests', @@ -289,7 +298,46 @@ function isWebExtension(manifest) { } return true; } -function packageLocalExtensionsStream(forWeb, disableMangle) { +/** + * Package local extensions that are known to not have native dependencies. Mutually exclusive to {@link packageNativeLocalExtensionsStream}. + * @param forWeb build the extensions that have web targets + * @param disableMangle disable the mangler + * @returns a stream + */ +function packageNonNativeLocalExtensionsStream(forWeb, disableMangle) { + return doPackageLocalExtensionsStream(forWeb, disableMangle, false); +} +/** + * Package local extensions that are known to have native dependencies. Mutually exclusive to {@link packageNonNativeLocalExtensionsStream}. + * @note it's possible that the extension does not have native dependencies for the current platform, especially if building for the web, + * but we simplify the logic here by having a flat list of extensions (See {@link nativeExtensions}) that are known to have native + * dependencies on some platform and thus should be packaged on the platform that they are building for. + * @param forWeb build the extensions that have web targets + * @param disableMangle disable the mangler + * @returns a stream + */ +function packageNativeLocalExtensionsStream(forWeb, disableMangle) { + return doPackageLocalExtensionsStream(forWeb, disableMangle, true); +} +/** + * Package all the local extensions... both those that are known to have native dependencies and those that are not. + * @param forWeb build the extensions that have web targets + * @param disableMangle disable the mangler + * @returns a stream + */ +function packageAllLocalExtensionsStream(forWeb, disableMangle) { + return es.merge([ + packageNonNativeLocalExtensionsStream(forWeb, disableMangle), + packageNativeLocalExtensionsStream(forWeb, disableMangle) + ]); +} +/** + * @param forWeb build the extensions that have web targets + * @param disableMangle disable the mangler + * @param native build the extensions that are marked as having native dependencies + */ +function doPackageLocalExtensionsStream(forWeb, disableMangle, native) { + const nativeExtensionsSet = new Set(nativeExtensions); const localExtensionsDescriptions = (glob.sync('extensions/*/package.json') .map(manifestPath => { const absoluteManifestPath = path.join(root, manifestPath); @@ -297,6 +345,7 @@ function packageLocalExtensionsStream(forWeb, disableMangle) { const extensionName = path.basename(extensionPath); return { name: extensionName, path: extensionPath, manifestPath: absoluteManifestPath }; }) + .filter(({ name }) => native ? nativeExtensionsSet.has(name) : !nativeExtensionsSet.has(name)) .filter(({ name }) => excludedExtensions.indexOf(name) === -1) .filter(({ name }) => builtInExtensions.every(b => b.name !== name)) .filter(({ manifestPath }) => (forWeb ? isWebExtension(require(manifestPath)) : true))); diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 14f2de9fef5..a881d3153da 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -277,6 +277,14 @@ export function fromGithub({ name, version, repo, sha256, metadata }: IExtension .pipe(packageJsonFilter.restore); } +/** + * All extensions that are known to have some native component and thus must be built on the + * platform that is being built. + */ +const nativeExtensions = [ + 'microsoft-authentication', +]; + const excludedExtensions = [ 'vscode-api-tests', 'vscode-colorize-tests', @@ -334,7 +342,49 @@ function isWebExtension(manifest: IExtensionManifest): boolean { return true; } -export function packageLocalExtensionsStream(forWeb: boolean, disableMangle: boolean): Stream { +/** + * Package local extensions that are known to not have native dependencies. Mutually exclusive to {@link packageNativeLocalExtensionsStream}. + * @param forWeb build the extensions that have web targets + * @param disableMangle disable the mangler + * @returns a stream + */ +export function packageNonNativeLocalExtensionsStream(forWeb: boolean, disableMangle: boolean): Stream { + return doPackageLocalExtensionsStream(forWeb, disableMangle, false); +} + +/** + * Package local extensions that are known to have native dependencies. Mutually exclusive to {@link packageNonNativeLocalExtensionsStream}. + * @note it's possible that the extension does not have native dependencies for the current platform, especially if building for the web, + * but we simplify the logic here by having a flat list of extensions (See {@link nativeExtensions}) that are known to have native + * dependencies on some platform and thus should be packaged on the platform that they are building for. + * @param forWeb build the extensions that have web targets + * @param disableMangle disable the mangler + * @returns a stream + */ +export function packageNativeLocalExtensionsStream(forWeb: boolean, disableMangle: boolean): Stream { + return doPackageLocalExtensionsStream(forWeb, disableMangle, true); +} + +/** + * Package all the local extensions... both those that are known to have native dependencies and those that are not. + * @param forWeb build the extensions that have web targets + * @param disableMangle disable the mangler + * @returns a stream + */ +export function packageAllLocalExtensionsStream(forWeb: boolean, disableMangle: boolean): Stream { + return es.merge([ + packageNonNativeLocalExtensionsStream(forWeb, disableMangle), + packageNativeLocalExtensionsStream(forWeb, disableMangle) + ]); +} + +/** + * @param forWeb build the extensions that have web targets + * @param disableMangle disable the mangler + * @param native build the extensions that are marked as having native dependencies + */ +function doPackageLocalExtensionsStream(forWeb: boolean, disableMangle: boolean, native: boolean): Stream { + const nativeExtensionsSet = new Set(nativeExtensions); const localExtensionsDescriptions = ( (glob.sync('extensions/*/package.json')) .map(manifestPath => { @@ -343,6 +393,7 @@ export function packageLocalExtensionsStream(forWeb: boolean, disableMangle: boo const extensionName = path.basename(extensionPath); return { name: extensionName, path: extensionPath, manifestPath: absoluteManifestPath }; }) + .filter(({ name }) => native ? nativeExtensionsSet.has(name) : !nativeExtensionsSet.has(name)) .filter(({ name }) => excludedExtensions.indexOf(name) === -1) .filter(({ name }) => builtInExtensions.every(b => b.name !== name)) .filter(({ manifestPath }) => (forWeb ? isWebExtension(require(manifestPath)) : true)) diff --git a/build/linux/debian/calculate-deps.js b/build/linux/debian/calculate-deps.js index 57934e65799..bbcb6bfc3de 100644 --- a/build/linux/debian/calculate-deps.js +++ b/build/linux/debian/calculate-deps.js @@ -72,19 +72,13 @@ function calculatePackageDeps(binaryPath, arch, chromiumSysroot, vscodeSysroot) // libgcc-s1 is a dependency of libc6. This hack can be removed once // support for Debian Buster and Ubuntu Bionic are dropped. // - // libgdk-pixbuf package has been renamed from libgdk-pixbuf2.0-0 to - // libgdk-pixbuf-2.0-0 in recent distros. Since we only ship a single - // linux package we cannot declare a dependeny on it. We can safely - // exclude this dependency as GTK depends on it and we depend on GTK. - // // Remove kerberos native module related dependencies as the versions // computed from sysroot will not satisfy the minimum supported distros // Refs https://github.com/microsoft/vscode/issues/188881. // TODO(deepak1556): remove this workaround in favor of computing the // versions from build container for native modules. const filteredDeps = depsStr.split(', ').filter(dependency => { - return !dependency.startsWith('libgcc-s1') && - !dependency.startsWith('libgdk-pixbuf'); + return !dependency.startsWith('libgcc-s1'); }).sort(); const requires = new Set(filteredDeps); return requires; diff --git a/build/linux/debian/calculate-deps.ts b/build/linux/debian/calculate-deps.ts index c44e241388b..92f8065f262 100644 --- a/build/linux/debian/calculate-deps.ts +++ b/build/linux/debian/calculate-deps.ts @@ -84,19 +84,13 @@ function calculatePackageDeps(binaryPath: string, arch: DebianArchString, chromi // libgcc-s1 is a dependency of libc6. This hack can be removed once // support for Debian Buster and Ubuntu Bionic are dropped. // - // libgdk-pixbuf package has been renamed from libgdk-pixbuf2.0-0 to - // libgdk-pixbuf-2.0-0 in recent distros. Since we only ship a single - // linux package we cannot declare a dependeny on it. We can safely - // exclude this dependency as GTK depends on it and we depend on GTK. - // // Remove kerberos native module related dependencies as the versions // computed from sysroot will not satisfy the minimum supported distros // Refs https://github.com/microsoft/vscode/issues/188881. // TODO(deepak1556): remove this workaround in favor of computing the // versions from build container for native modules. const filteredDeps = depsStr.split(', ').filter(dependency => { - return !dependency.startsWith('libgcc-s1') && - !dependency.startsWith('libgdk-pixbuf'); + return !dependency.startsWith('libgcc-s1'); }).sort(); const requires = new Set(filteredDeps); return requires; diff --git a/build/linux/rpm/dep-lists.js b/build/linux/rpm/dep-lists.js index 04abee1d30a..6857f6b7c1a 100644 --- a/build/linux/rpm/dep-lists.js +++ b/build/linux/rpm/dep-lists.js @@ -70,7 +70,6 @@ exports.referenceGeneratedDepsByArch = { 'libgcc_s.so.1(GCC_3.0)(64bit)', 'libgcc_s.so.1(GCC_3.3)(64bit)', 'libgcc_s.so.1(GCC_4.2.0)(64bit)', - 'libgdk_pixbuf-2.0.so.0()(64bit)', 'libgio-2.0.so.0()(64bit)', 'libglib-2.0.so.0()(64bit)', 'libgobject-2.0.so.0()(64bit)', @@ -160,7 +159,6 @@ exports.referenceGeneratedDepsByArch = { 'libgcc_s.so.1(GCC_3.0)', 'libgcc_s.so.1(GCC_3.5)', 'libgcc_s.so.1(GCC_4.3.0)', - 'libgdk_pixbuf-2.0.so.0', 'libgio-2.0.so.0', 'libglib-2.0.so.0', 'libgobject-2.0.so.0', @@ -255,7 +253,6 @@ exports.referenceGeneratedDepsByArch = { 'libgcc_s.so.1(GCC_3.3)(64bit)', 'libgcc_s.so.1(GCC_4.2.0)(64bit)', 'libgcc_s.so.1(GCC_4.5.0)(64bit)', - 'libgdk_pixbuf-2.0.so.0()(64bit)', 'libgio-2.0.so.0()(64bit)', 'libglib-2.0.so.0()(64bit)', 'libgobject-2.0.so.0()(64bit)', diff --git a/build/linux/rpm/dep-lists.ts b/build/linux/rpm/dep-lists.ts index 8761e40cb1e..8fd025e51c2 100644 --- a/build/linux/rpm/dep-lists.ts +++ b/build/linux/rpm/dep-lists.ts @@ -69,7 +69,6 @@ export const referenceGeneratedDepsByArch = { 'libgcc_s.so.1(GCC_3.0)(64bit)', 'libgcc_s.so.1(GCC_3.3)(64bit)', 'libgcc_s.so.1(GCC_4.2.0)(64bit)', - 'libgdk_pixbuf-2.0.so.0()(64bit)', 'libgio-2.0.so.0()(64bit)', 'libglib-2.0.so.0()(64bit)', 'libgobject-2.0.so.0()(64bit)', @@ -159,7 +158,6 @@ export const referenceGeneratedDepsByArch = { 'libgcc_s.so.1(GCC_3.0)', 'libgcc_s.so.1(GCC_3.5)', 'libgcc_s.so.1(GCC_4.3.0)', - 'libgdk_pixbuf-2.0.so.0', 'libgio-2.0.so.0', 'libglib-2.0.so.0', 'libgobject-2.0.so.0', @@ -254,7 +252,6 @@ export const referenceGeneratedDepsByArch = { 'libgcc_s.so.1(GCC_3.3)(64bit)', 'libgcc_s.so.1(GCC_4.2.0)(64bit)', 'libgcc_s.so.1(GCC_4.5.0)(64bit)', - 'libgdk_pixbuf-2.0.so.0()(64bit)', 'libgio-2.0.so.0()(64bit)', 'libglib-2.0.so.0()(64bit)', 'libgobject-2.0.so.0()(64bit)', diff --git a/build/package-lock.json b/build/package-lock.json index 1e373f2e68f..e8f5ce67f24 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -9,8 +9,11 @@ "version": "1.0.0", "license": "MIT", "devDependencies": { + "@azure/core-auth": "^1.9.0", "@azure/cosmos": "^3", "@azure/identity": "^4.2.1", + "@azure/msal-node": "^2.16.1", + "@azure/storage-blob": "^12.25.0", "@electron/get": "^2.0.0", "@types/ansi-colors": "^3.2.0", "@types/byline": "^4.2.32", @@ -26,6 +29,7 @@ "@types/gulp-rename": "^0.0.33", "@types/gulp-sort": "^2.0.4", "@types/gulp-sourcemaps": "^0.0.32", + "@types/jws": "^3.2.10", "@types/mime": "0.0.29", "@types/minimatch": "^3.0.3", "@types/minimist": "^1.2.1", @@ -47,6 +51,7 @@ "gulp-merge-json": "^2.1.1", "gulp-sort": "^2.0.0", "jsonc-parser": "^2.3.0", + "jws": "^4.0.0", "mime": "^1.4.1", "source-map": "0.6.1", "ternary-stream": "^3.0.0", @@ -73,107 +78,188 @@ "node": ">=8.0.0" } }, - "node_modules/@azure/core-asynciterator-polyfill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@azure/core-asynciterator-polyfill/-/core-asynciterator-polyfill-1.0.0.tgz", - "integrity": "sha512-kmv8CGrPfN9SwMwrkiBK9VTQYxdFQEGe0BmQk+M8io56P9KNzpAxcWE/1fxJj7uouwN4kXF0BHW8DNlgx+wtCg==", - "dev": true - }, "node_modules/@azure/core-auth": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.5.0.tgz", - "integrity": "sha512-udzoBuYG1VBoHVohDTrvKjyzel34zt77Bhp7dQntVGGD0ehVq48owENbBG8fIgkHRNUBQH5k1r0hpoMu5L8+kw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.9.0.tgz", + "integrity": "sha512-FPwHpZywuyasDSLMqJ6fhbOK3TqUdviZNF8OqRGA4W5Ewib2lEEZ+pBsYcBa88B2NGO/SEnYPGhyBqNlE8ilSw==", "dev": true, + "license": "MIT", "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-util": "^1.1.0", - "tslib": "^2.2.0" + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.11.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@azure/core-client": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.5.0.tgz", - "integrity": "sha512-YNk8i9LT6YcFdFO+RRU0E4Ef+A8Y5lhXo6lz61rwbG8Uo7kSqh0YqK04OexiilM43xd6n3Y9yBhLnb1NFNI9dA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.9.2.tgz", + "integrity": "sha512-kRdry/rav3fUKHl/aDLd/pDLcB+4pOFwPPTVEExuMyaI5r+JBbMWqRbCY1pn5BniDaU3lRxO9eaQ1AmSMehl/w==", "dev": true, + "license": "MIT", "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-asynciterator-polyfill": "^1.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-rest-pipeline": "^1.5.0", - "@azure/core-tracing": "1.0.0-preview.13", + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.4.0", + "@azure/core-rest-pipeline": "^1.9.1", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.6.1", "@azure/logger": "^1.0.0", - "tslib": "^2.2.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" } }, - "node_modules/@azure/core-client/node_modules/@azure/core-tracing": { - "version": "1.0.0-preview.13", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.0-preview.13.tgz", - "integrity": "sha512-KxDlhXyMlh2Jhj2ykX6vNEU0Vou4nHr025KoSEiz7cS3BNiHNaZcdECk/DmLkEB0as5T7b/TpRcehJ5yV6NeXQ==", + "node_modules/@azure/core-client/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/api": "^1.0.1", - "tslib": "^2.2.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-http-compat": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.1.2.tgz", + "integrity": "sha512-5MnV1yqzZwgNLLjlizsU3QqOeQChkIXw781Fwh1xdAqJR5AA32IUaq6xv1BICJvfbHoa+JYcaij2HFkhLbNTJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-client": "^1.3.0", + "@azure/core-rest-pipeline": "^1.3.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-http-compat/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-lro/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.7.0.tgz", - "integrity": "sha512-e2awPzwMKHrmvYgZ0qIKNkqnCM1QoDs7A0rOiS3OSAlOQOz/kL7PPKHXwFMuBeaRvS8i7fgobJn79q2Cji5f+Q==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.18.0.tgz", + "integrity": "sha512-QSoGUp4Eq/gohEFNJaUOwTN7BCc2nHTjjbm75JT0aD7W65PWM1H/tItz0GsABn22uaKyGxiMhWQLt2r+FGU89Q==", "dev": true, + "license": "MIT", "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-tracing": "1.0.0-preview.13", + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.8.0", + "@azure/core-tracing": "^1.0.1", + "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", - "form-data": "^4.0.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "tslib": "^2.2.0", - "uuid": "^8.3.0" + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" } }, - "node_modules/@azure/core-rest-pipeline/node_modules/@azure/core-tracing": { - "version": "1.0.0-preview.13", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.0-preview.13.tgz", - "integrity": "sha512-KxDlhXyMlh2Jhj2ykX6vNEU0Vou4nHr025KoSEiz7cS3BNiHNaZcdECk/DmLkEB0as5T7b/TpRcehJ5yV6NeXQ==", + "node_modules/@azure/core-rest-pipeline/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/api": "^1.0.1", - "tslib": "^2.2.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" } }, "node_modules/@azure/core-tracing": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.1.tgz", - "integrity": "sha512-I5CGMoLtX+pI17ZdiFJZgxMJApsK6jjfm85hpgp3oazCdq5Wxgh4wMr7ge/TTWW1B5WBuvIOI1fMU/FrOAMKrw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.2.0.tgz", + "integrity": "sha512-UKTiEJPkWcESPYJz3X5uKRYyOcJD+4nYph+KpfdPRnQJVrZfk0KJgdnaAWKfhsBBtAf/D58Az4AvCJEmWgIBAg==", "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.2.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" } }, "node_modules/@azure/core-util": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.9.0.tgz", - "integrity": "sha512-AfalUQ1ZppaKuxPPMsFEUdX6GZPB3d9paR9d/TTL7Ow2De8cJaC7ibi7kWVlFAVPCYo31OcnGymc0R89DX8Oaw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.11.0.tgz", + "integrity": "sha512-DxOSLua+NdpWoSqULhjDyAZTXFdP/LKkqtYuxxz1SCN289zk3OG8UOpnCQAz/tygyACBtWp/BoO72ptK7msY8g==", "dev": true, + "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", "tslib": "^2.6.2" @@ -194,6 +280,20 @@ "node": ">=18.0.0" } }, + "node_modules/@azure/core-xml": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.4.4.tgz", + "integrity": "sha512-J4FYAqakGXcbfeZjwjMzjNcpcH4E+JtEBv+xcV1yL0Ydn/6wbQfeFKTCHh9wttAi0lmajHw7yBbHPRG+YHckZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@azure/cosmos": { "version": "3.17.3", "resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-3.17.3.tgz", @@ -277,12 +377,13 @@ } }, "node_modules/@azure/msal-node": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.9.2.tgz", - "integrity": "sha512-8tvi6Cos3m+0KmRbPjgkySXi+UQU/QiuVRFnrxIwt5xZlEEFa69O04RTaNESGgImyBBlYbo2mfE8/U8Bbdk1WQ==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.1.tgz", + "integrity": "sha512-1NEFpTmMMT2A7RnZuvRl/hUmJU+GLPjh+ShyIqPktG2PvSd2yvPnzGd/BxIBAAvJG5nr9lH4oYcQXepDbaE7fg==", "dev": true, + "license": "MIT", "dependencies": { - "@azure/msal-common": "14.12.0", + "@azure/msal-common": "14.16.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, @@ -290,6 +391,54 @@ "node": ">=16" } }, + "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { + "version": "14.16.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.0.tgz", + "integrity": "sha512-1KOZj9IpcDSwpNiQNjt0jDYZpQvNZay7QAEi/5DLubay40iGYtLzya/jbjRPLyOTZhEKyL1MzPuw2HqBCjceYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/storage-blob": { + "version": "12.25.0", + "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.25.0.tgz", + "integrity": "sha512-oodouhA3nCCIh843tMMbxty3WqfNT+Vgzj3Xo5jqR9UPnzq3d7mzLjlHAYz7lW+b4km3SIgz+NAgztvhm7Z6kQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.4.0", + "@azure/core-client": "^1.6.2", + "@azure/core-http-compat": "^2.0.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.1.1", + "@azure/core-rest-pipeline": "^1.10.1", + "@azure/core-tracing": "^1.1.2", + "@azure/core-util": "^1.6.1", + "@azure/core-xml": "^1.4.3", + "@azure/logger": "^1.0.0", + "events": "^3.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/storage-blob/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@electron/asar": { "version": "3.2.10", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.10.tgz", @@ -743,15 +892,6 @@ "node": ">= 12.13.0" } }, - "node_modules/@opentelemetry/api": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.0.3.tgz", - "integrity": "sha512-puWxACExDe9nxbBB3lOymQFrLYml2dVOrd7USiVRnSbgXE+KwBu+HxFvxrzfqsiSda9IWsXJG1ef7C1O2/GmKQ==", - "dev": true, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -776,15 +916,6 @@ "node": ">=10" } }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/@types/ansi-colors": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@types/ansi-colors/-/ansi-colors-3.2.0.tgz", @@ -969,6 +1100,16 @@ "integrity": "sha512-/siF86XrwDKLuHe8l7h6NhrAWgLdgqbxmjZv9NvGWmgYRZoTipkjKiWb0SQHy/jcR+ee0GvbG6uGd+LEBMGNvA==", "dev": true }, + "node_modules/@types/jws": { + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/@types/jws/-/jws-3.2.10.tgz", + "integrity": "sha512-cOevhttJmssERB88/+XvZXvsq5m9JLKZNUiGfgjUb5lcPRdV2ZQciU6dU76D/qXXFYpSqkP3PrSg4hMTiafTZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -1197,6 +1338,16 @@ "node": ">=10" } }, + "node_modules/@vscode/vsce/node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -1207,15 +1358,16 @@ } }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", "dev": true, + "license": "MIT", "dependencies": { - "debug": "4" + "debug": "^4.3.4" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/ansi-colors": { @@ -1324,12 +1476,6 @@ "node": ">= 0.10" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k= sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true - }, "node_modules/azure-devops-node-api": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-11.2.0.tgz", @@ -1705,18 +1851,6 @@ "color-support": "bin.js" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/compare-version": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", @@ -1881,15 +2015,6 @@ "node": ">= 0.4" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk= sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-libc": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", @@ -2207,6 +2332,29 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true }, + "node_modules/fast-xml-parser": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", + "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -2261,20 +2409,6 @@ "integrity": "sha512-Pqq5NnT78ehvUnAk/We/Jr22vSvanRlFTpAmQ88xBY/M1TlHe+P0ILuEyXS595ysdGfaj22634LBkGMA2GTcpA==", "dev": true }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -2631,17 +2765,17 @@ "dev": true }, "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, + "license": "MIT", "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/http2-wrapper": { @@ -2658,16 +2792,17 @@ } }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", "dev": true, + "license": "MIT", "dependencies": { - "agent-base": "6", + "agent-base": "^7.0.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/ieee754": { @@ -2959,6 +3094,7 @@ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", "dev": true, + "license": "MIT", "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" @@ -3111,27 +3247,6 @@ "node": ">=4" } }, - "node_modules/mime-db": { - "version": "1.45.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz", - "integrity": "sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.28", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz", - "integrity": "sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ==", - "dev": true, - "dependencies": { - "mime-db": "1.45.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -3904,6 +4019,13 @@ "node": ">=0.10.0" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", + "dev": true, + "license": "MIT" + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -4371,15 +4493,6 @@ "fd-slicer": "~1.1.0" } }, - "node_modules/yazl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", - "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", - "dev": true, - "dependencies": { - "buffer-crc32": "~0.2.3" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/build/package.json b/build/package.json index aa94a210e9c..c85b600983f 100644 --- a/build/package.json +++ b/build/package.json @@ -3,8 +3,11 @@ "version": "1.0.0", "license": "MIT", "devDependencies": { + "@azure/core-auth": "^1.9.0", "@azure/cosmos": "^3", "@azure/identity": "^4.2.1", + "@azure/msal-node": "^2.16.1", + "@azure/storage-blob": "^12.25.0", "@electron/get": "^2.0.0", "@types/ansi-colors": "^3.2.0", "@types/byline": "^4.2.32", @@ -20,6 +23,7 @@ "@types/gulp-rename": "^0.0.33", "@types/gulp-sort": "^2.0.4", "@types/gulp-sourcemaps": "^0.0.32", + "@types/jws": "^3.2.10", "@types/mime": "0.0.29", "@types/minimatch": "^3.0.3", "@types/minimist": "^1.2.1", @@ -41,6 +45,7 @@ "gulp-merge-json": "^2.1.1", "gulp-sort": "^2.0.0", "jsonc-parser": "^2.3.0", + "jws": "^4.0.0", "mime": "^1.4.1", "source-map": "0.6.1", "ternary-stream": "^3.0.0", diff --git a/cgmanifest.json b/cgmanifest.json index 57fd8db5f45..5be1cdf48cd 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -528,12 +528,12 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "db2050e9d24022b5d7d1bf4873e4725d75383a1f" + "commitHash": "bcd7ac5481acc54ba28c6399b279d843ba427b19" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "32.2.1" + "version": "32.2.3" }, { "component": { diff --git a/cli/src/bin/code/legacy_args.rs b/cli/src/bin/code/legacy_args.rs index 5a8e0f01071..0bd92c92fd3 100644 --- a/cli/src/bin/code/legacy_args.rs +++ b/cli/src/bin/code/legacy_args.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use cli::commands::args::{ CliCore, Commands, DesktopCodeOptions, ExtensionArgs, ExtensionSubcommand, - InstallExtensionArgs, ListExtensionArgs, UninstallExtensionArgs, DownloadExtensionArgs, + InstallExtensionArgs, ListExtensionArgs, UninstallExtensionArgs, }; /// Tries to parse the argv using the legacy CLI interface, looking for its @@ -64,7 +64,6 @@ pub fn try_parse_legacy( // Now translate them to subcommands. // --list-extensions -> ext list // --update-extensions -> update - // --download-extension -> ext download // --install-extension=id -> ext install // --uninstall-extension=id -> ext uninstall // --status -> status @@ -80,17 +79,6 @@ pub fn try_parse_legacy( })), ..Default::default() }) - } else if let Some(exts) = args.get("download-extension") { - Some(CliCore { - subcommand: Some(Commands::Extension(ExtensionArgs { - subcommand: ExtensionSubcommand::Download(DownloadExtensionArgs { - id: exts.to_vec(), - location: get_first_arg_value("location"), - }), - desktop_code_options, - })), - ..Default::default() - }) } else if let Some(exts) = args.remove("install-extension") { Some(CliCore { subcommand: Some(Commands::Extension(ExtensionArgs { diff --git a/cli/src/commands/args.rs b/cli/src/commands/args.rs index 3f4dfd9b7a4..21efc1c6667 100644 --- a/cli/src/commands/args.rs +++ b/cli/src/commands/args.rs @@ -272,8 +272,6 @@ pub enum ExtensionSubcommand { Uninstall(UninstallExtensionArgs), /// Update the installed extensions. Update, - /// Download an extension. - Download(DownloadExtensionArgs), } impl ExtensionSubcommand { @@ -307,16 +305,6 @@ impl ExtensionSubcommand { ExtensionSubcommand::Update => { target.push("--update-extensions".to_string()); } - ExtensionSubcommand::Download(args) => { - for id in args.id.iter() { - target.push(format!("--download-extension={id}")); - } - if let Some(location) = &args.location { - if !location.is_empty() { - target.push(format!("--location={location}")); - } - } - } } } } @@ -359,21 +347,6 @@ pub struct UninstallExtensionArgs { pub id: Vec, } -#[derive(Args, Debug, Clone)] -pub struct DownloadExtensionArgs { - /// Id of the extension to download. The identifier of an - /// extension is '${publisher}.${name}'. Should provide '--location' to specify the location to download the VSIX. - /// To download a specific version provide '@${version}'. - /// For example: 'vscode.csharp@1.2.3'. - #[clap(name = "ext-id")] - pub id: Vec, - - /// Specify the location to download the VSIX. - #[clap(long, value_name = "location")] - pub location: Option, - -} - #[derive(Args, Debug, Clone)] pub struct VersionArgs { #[clap(subcommand)] diff --git a/cli/src/commands/serve_web.rs b/cli/src/commands/serve_web.rs index eef4f331d10..3dedf4b3f05 100644 --- a/cli/src/commands/serve_web.rs +++ b/cli/src/commands/serve_web.rs @@ -252,6 +252,7 @@ fn get_release_from_path(path: &str, platform: Platform) -> Option<(Release, Str let (quality_commit, remaining) = path.split_at(i); let (quality, commit) = quality_commit.split_at(quality_commit_sep); + let commit = &commit[1..]; if !is_commit_hash(commit) { return None; diff --git a/extensions/git/package.json b/extensions/git/package.json index 9743287dfb9..5e65475fe73 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -34,6 +34,7 @@ "scmValidation", "tabInputMultiDiff", "tabInputTextMerge", + "textEditorDiffInformation", "timeline" ], "categories": [ @@ -567,7 +568,7 @@ "title": "%command.pull%", "icon": "$(repo-pull)", "category": "Git", - "enablement": "!operationInProgress" + "enablement": "!operationInProgress && scmCurrentHistoryItemRefInFilter && scmCurrentHistoryItemRefHasRemote" }, { "command": "git.push", @@ -616,7 +617,7 @@ "title": "%command.push%", "icon": "$(repo-push)", "category": "Git", - "enablement": "!operationInProgress" + "enablement": "!operationInProgress && scmCurrentHistoryItemRefInFilter && scmCurrentHistoryItemRefHasRemote" }, { "command": "git.cherryPick", @@ -1968,8 +1969,23 @@ "scm/history/title": [ { "command": "git.fetchAll", - "group": "navigation@999", + "group": "navigation@900", "when": "scmProvider == git" + }, + { + "command": "git.pullRef", + "group": "navigation@901", + "when": "scmProvider == git" + }, + { + "command": "git.pushRef", + "when": "scmProvider == git && scmCurrentHistoryItemRefHasRemote", + "group": "navigation@902" + }, + { + "command": "git.publish", + "when": "scmProvider == git && !scmCurrentHistoryItemRefHasRemote", + "group": "navigation@903" } ], "scm/historyItem/context": [ diff --git a/extensions/git/tsconfig.json b/extensions/git/tsconfig.json index 75fb8217df7..82ba0abe546 100644 --- a/extensions/git/tsconfig.json +++ b/extensions/git/tsconfig.json @@ -20,6 +20,7 @@ "../../src/vscode-dts/vscode.proposed.scmTextDocument.d.ts", "../../src/vscode-dts/vscode.proposed.tabInputMultiDiff.d.ts", "../../src/vscode-dts/vscode.proposed.tabInputTextMerge.d.ts", + "../../src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts", "../../src/vscode-dts/vscode.proposed.timeline.d.ts", "../../src/vscode-dts/vscode.proposed.quickInputButtonLocation.d.ts", "../types/lib.textEncoder.d.ts" diff --git a/extensions/microsoft-authentication/.vscodeignore b/extensions/microsoft-authentication/.vscodeignore index 98b90d34d82..e7feddb5da8 100644 --- a/extensions/microsoft-authentication/.vscodeignore +++ b/extensions/microsoft-authentication/.vscodeignore @@ -12,3 +12,4 @@ vsc-extension-quickstart.md **/tslint.json **/*.map **/*.ts +packageMocks/ diff --git a/extensions/microsoft-authentication/extension.webpack.config.js b/extensions/microsoft-authentication/extension.webpack.config.js index 45600607fc5..a85bb7b9b77 100644 --- a/extensions/microsoft-authentication/extension.webpack.config.js +++ b/extensions/microsoft-authentication/extension.webpack.config.js @@ -8,10 +8,47 @@ 'use strict'; const withDefaults = require('../shared.webpack.config'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); +const path = require('path'); +const { NormalModuleReplacementPlugin } = require('webpack'); + +const isWindows = process.platform === 'win32'; module.exports = withDefaults({ context: __dirname, entry: { extension: './src/extension.ts' - } + }, + externals: { + // The @azure/msal-node-runtime package requires this native node module (.node). + // It is currently only included on Windows, but the package handles unsupported platforms + // gracefully. + './msal-node-runtime': 'commonjs ./msal-node-runtime' + }, + resolve: { + alias: { + 'keytar': path.resolve(__dirname, 'packageMocks', 'keytar', 'index.js') + } + }, + plugins: [ + ...withDefaults.nodePlugins(__dirname), + new CopyWebpackPlugin({ + patterns: [ + { + // The native files we need to ship with the extension + from: '**/dist/msal*.(node|dll)', + to: '[name][ext]', + // These will only be present on Windows for now + noErrorOnMissing: !isWindows + } + ] + }), + // We don't use the feature that uses Dpapi, so we can just replace it with a mock. + // This is a bit of a hack, but it's the easiest way to do it. Really, msal should + // handle when this native node module is not available. + new NormalModuleReplacementPlugin( + /\.\.\/Dpapi\.mjs/, + path.resolve(__dirname, 'packageMocks', 'dpapi', 'dpapi.js') + ) + ] }); diff --git a/extensions/microsoft-authentication/package-lock.json b/extensions/microsoft-authentication/package-lock.json index 8f05b14f02a..c52e019da9a 100644 --- a/extensions/microsoft-authentication/package-lock.json +++ b/extensions/microsoft-authentication/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "@azure/ms-rest-azure-env": "^2.0.0", "@azure/msal-node": "^2.13.1", + "@azure/msal-node-extensions": "^1.3.0", "@vscode/extension-telemetry": "^0.9.0", + "keytar": "file:./packageMocks/keytar", "vscode-tas-client": "^0.1.84" }, "devDependencies": { @@ -51,6 +53,40 @@ "node": ">=16" } }, + "node_modules/@azure/msal-node-extensions": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node-extensions/-/msal-node-extensions-1.3.0.tgz", + "integrity": "sha512-7rXN+9hDm3NncIfNnMyoFtsnz2AlUtmK5rsY3P+fhhbH+GOk0W5Y1BASvAB6RCcKdO+qSIK3ZA6VHQYy4iS/1w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.15.0", + "@azure/msal-node-runtime": "^0.17.1", + "keytar": "^7.8.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@azure/msal-node-extensions/node_modules/@azure/msal-common": { + "version": "14.15.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.15.0.tgz", + "integrity": "sha512-ImAQHxmpMneJ/4S8BRFhjt1MZ3bppmpRPYYNyzeQPeFN288YKbb8TmmISQEbtfkQ1BPASvYZU5doIZOPBAqENQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node-extensions/packageMocks/keytar": { + "extraneous": true + }, + "node_modules/@azure/msal-node-runtime": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node-runtime/-/msal-node-runtime-0.17.1.tgz", + "integrity": "sha512-qAfTg+iGJsg+XvD9nmknI63+XuoX32oT+SX4wJdFz7CS6ETVpSHoroHVaUmsTU1H7H0+q1/ZkP988gzPRMYRsg==", + "hasInstallScript": true, + "license": "MIT" + }, "node_modules/@microsoft/1ds-core-js": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@microsoft/1ds-core-js/-/1ds-core-js-4.0.3.tgz", @@ -315,6 +351,10 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keytar": { + "resolved": "packageMocks/keytar", + "link": true + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -376,6 +416,9 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/packageMocks/keytar": { + "extraneous": true + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -435,6 +478,9 @@ "engines": { "vscode": "^1.85.0" } + }, + "packageMocks/keytar": { + "version": "7.9.0" } } } diff --git a/extensions/microsoft-authentication/package.json b/extensions/microsoft-authentication/package.json index 9eea07ec024..0f5b707f18a 100644 --- a/extensions/microsoft-authentication/package.json +++ b/extensions/microsoft-authentication/package.json @@ -14,7 +14,8 @@ ], "activationEvents": [], "enabledApiProposals": [ - "idToken" + "idToken", + "nativeWindowHandle" ], "capabilities": { "virtualWorkspaces": true, @@ -131,7 +132,9 @@ "dependencies": { "@azure/ms-rest-azure-env": "^2.0.0", "@azure/msal-node": "^2.13.1", + "@azure/msal-node-extensions": "^1.3.0", "@vscode/extension-telemetry": "^0.9.0", + "keytar": "file:./packageMocks/keytar", "vscode-tas-client": "^0.1.84" }, "repository": { diff --git a/extensions/microsoft-authentication/packageMocks/dpapi/dpapi.js b/extensions/microsoft-authentication/packageMocks/dpapi/dpapi.js new file mode 100644 index 00000000000..636112a188f --- /dev/null +++ b/extensions/microsoft-authentication/packageMocks/dpapi/dpapi.js @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +class defaultDpapi { + protectData() { + throw new Error('Dpapi bindings unavailable'); + } + unprotectData() { + throw new Error('Dpapi bindings unavailable'); + } +} +const Dpapi = new defaultDpapi(); +export { Dpapi }; diff --git a/src/vscode-dts/vscode.proposed.lmTools.d.ts b/extensions/microsoft-authentication/packageMocks/keytar/index.js similarity index 71% rename from src/vscode-dts/vscode.proposed.lmTools.d.ts rename to extensions/microsoft-authentication/packageMocks/keytar/index.js index 63084e91433..418d592ffdd 100644 --- a/src/vscode-dts/vscode.proposed.lmTools.d.ts +++ b/extensions/microsoft-authentication/packageMocks/keytar/index.js @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 15 - -declare module 'vscode' { -} +exports.setPassword = () => Promise.resolve(); +exports.getPassword = () => Promise.resolve(); +exports.deletePassword = () => Promise.resolve(); diff --git a/extensions/microsoft-authentication/packageMocks/keytar/package.json b/extensions/microsoft-authentication/packageMocks/keytar/package.json new file mode 100644 index 00000000000..0014152ac93 --- /dev/null +++ b/extensions/microsoft-authentication/packageMocks/keytar/package.json @@ -0,0 +1,7 @@ +{ + "name": "keytar", + "version": "7.9.0", + "description": "OVERRIDE Keytar since we don't need the feature", + "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node", + "main": "index.js" +} diff --git a/extensions/microsoft-authentication/src/common/accountAccess.ts b/extensions/microsoft-authentication/src/common/accountAccess.ts new file mode 100644 index 00000000000..a8fdeefef98 --- /dev/null +++ b/extensions/microsoft-authentication/src/common/accountAccess.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, Event, EventEmitter, SecretStorage } from 'vscode'; +import { AccountInfo } from '@azure/msal-node'; + +interface IAccountAccess { + onDidAccountAccessChange: Event; + isAllowedAccess(account: AccountInfo): boolean; + setAllowedAccess(account: AccountInfo, allowed: boolean): void; +} + +export class ScopedAccountAccess implements IAccountAccess { + private readonly _onDidAccountAccessChangeEmitter = new EventEmitter(); + readonly onDidAccountAccessChange = this._onDidAccountAccessChangeEmitter.event; + + private readonly _accountAccessSecretStorage: AccountAccessSecretStorage; + + private value = new Array(); + + constructor( + private readonly _secretStorage: SecretStorage, + private readonly _cloudName: string, + private readonly _clientId: string, + private readonly _authority: string + ) { + this._accountAccessSecretStorage = new AccountAccessSecretStorage(this._secretStorage, this._cloudName, this._clientId, this._authority); + this._accountAccessSecretStorage.onDidChange(() => this.update()); + } + + initialize() { + return this.update(); + } + + isAllowedAccess(account: AccountInfo): boolean { + return this.value.includes(account.homeAccountId); + } + + async setAllowedAccess(account: AccountInfo, allowed: boolean): Promise { + if (allowed) { + if (this.value.includes(account.homeAccountId)) { + return; + } + await this._accountAccessSecretStorage.store([...this.value, account.homeAccountId]); + return; + } + await this._accountAccessSecretStorage.store(this.value.filter(id => id !== account.homeAccountId)); + } + + private async update() { + const current = new Set(this.value); + const value = await this._accountAccessSecretStorage.get(); + + this.value = value ?? []; + if (current.size !== this.value.length || !this.value.every(id => current.has(id))) { + this._onDidAccountAccessChangeEmitter.fire(); + } + } +} + +export class AccountAccessSecretStorage { + private _disposable: Disposable; + + private readonly _onDidChangeEmitter = new EventEmitter; + readonly onDidChange: Event = this._onDidChangeEmitter.event; + + private readonly _key = `accounts-${this._cloudName}-${this._clientId}-${this._authority}`; + + constructor( + private readonly _secretStorage: SecretStorage, + private readonly _cloudName: string, + private readonly _clientId: string, + private readonly _authority: string + ) { + this._disposable = Disposable.from( + this._onDidChangeEmitter, + this._secretStorage.onDidChange(e => { + if (e.key === this._key) { + this._onDidChangeEmitter.fire(); + } + }) + ); + } + + async get(): Promise { + const value = await this._secretStorage.get(this._key); + if (!value) { + return undefined; + } + return JSON.parse(value); + } + + store(value: string[]): Thenable { + return this._secretStorage.store(this._key, JSON.stringify(value)); + } + + delete(): Thenable { + return this._secretStorage.delete(this._key); + } + + dispose() { + this._disposable.dispose(); + } +} diff --git a/extensions/microsoft-authentication/src/node/authProvider.ts b/extensions/microsoft-authentication/src/node/authProvider.ts index 0c285e0cb2b..81c4008d381 100644 --- a/extensions/microsoft-authentication/src/node/authProvider.ts +++ b/extensions/microsoft-authentication/src/node/authProvider.ts @@ -156,6 +156,7 @@ export class MsalAuthProvider implements AuthenticationProvider { let result: AuthenticationResult | undefined; try { + const windowHandle = env.handle ? Buffer.from(env.handle, 'base64') : undefined; result = await cachedPca.acquireTokenInteractive({ openBrowser: async (url: string) => { await env.openExternal(Uri.parse(url)); }, scopes: scopeData.scopesToSend, @@ -167,7 +168,8 @@ export class MsalAuthProvider implements AuthenticationProvider { loginHint: options.account?.label, // If we aren't logging in to a specific account, then we can use the prompt to make sure they get // the option to choose a different account. - prompt: options.account?.label ? undefined : 'select_account' + prompt: options.account?.label ? undefined : 'select_account', + windowHandle }); } catch (e) { if (e instanceof CancellationError) { @@ -196,12 +198,14 @@ export class MsalAuthProvider implements AuthenticationProvider { // The user wants to try the loopback client or we got an error likely due to spinning up the server const loopbackClient = new UriHandlerLoopbackClient(this._uriHandler, redirectUri, this._logger); try { + const windowHandle = env.handle ? Buffer.from(env.handle) : undefined; result = await cachedPca.acquireTokenInteractive({ openBrowser: (url: string) => loopbackClient.openBrowser(url), scopes: scopeData.scopesToSend, loopbackClient, loginHint: options.account?.label, - prompt: options.account?.label ? undefined : 'select_account' + prompt: options.account?.label ? undefined : 'select_account', + windowHandle }); } catch (e) { this._telemetryReporter.sendLoginFailedEvent(); diff --git a/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts index c679610466a..e23ffeb3b9c 100644 --- a/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts +++ b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { PublicClientApplication, AccountInfo, Configuration, SilentFlowRequest, AuthenticationResult, InteractiveRequest, LogLevel } from '@azure/msal-node'; +import { NativeBrokerPlugin } from '@azure/msal-node-extensions'; import { Disposable, Memento, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter } from 'vscode'; import { Delayer, raceCancellationAndTimeoutError } from '../common/async'; import { SecretStorageCachePlugin } from '../common/cachePlugin'; import { MsalLoggerOptions } from '../common/loggerOptions'; import { ICachedPublicClientApplication } from '../common/publicClientCache'; +import { ScopedAccountAccess } from '../common/accountAccess'; export class CachedPublicClientApplication implements ICachedPublicClientApplication { private _pca: PublicClientApplication; @@ -24,19 +26,24 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica // Include the prefix as a differentiator to other secrets `pca:${JSON.stringify({ clientId: this._clientId, authority: this._authority })}` ); + private readonly _accountAccess = new ScopedAccountAccess(this._secretStorage, this._cloudName, this._clientId, this._authority); private readonly _config: Configuration = { auth: { clientId: this._clientId, authority: this._authority }, system: { loggerOptions: { correlationId: `${this._clientId}] [${this._authority}`, loggerCallback: (level, message, containsPii) => this._loggerOptions.loggerCallback(level, message, containsPii), - logLevel: LogLevel.Trace + logLevel: LogLevel.Info } }, + broker: { + nativeBrokerPlugin: new NativeBrokerPlugin() + }, cache: { cachePlugin: this._secretStorageCachePlugin } }; + private readonly _isBrokerAvailable = this._config.broker?.nativeBrokerPlugin?.isBrokerAvailable ?? false; /** * We keep track of the last time an account was removed so we can recreate the PCA if we detect that an account was removed. @@ -59,6 +66,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica constructor( private readonly _clientId: string, private readonly _authority: string, + private readonly _cloudName: string, private readonly _globalMemento: Memento, private readonly _secretStorage: SecretStorage, private readonly _logger: LogOutputChannel @@ -76,8 +84,11 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica get clientId(): string { return this._clientId; } get authority(): string { return this._authority; } - initialize(): Promise { - return this._update(); + async initialize(): Promise { + if (this._isBrokerAvailable) { + await this._accountAccess.initialize(); + } + await this._update(); } dispose(): void { @@ -88,7 +99,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] starting...`); const result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(request)); this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] got result`); - if (result.account && !result.fromCache) { + if (result.account && !result.fromCache && this._verifyIfUsingBroker(result)) { this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] firing event due to change`); this._setupRefresh(result); this._onDidAccountsChangeEmitter.fire({ added: [], changed: [result.account], deleted: [] }); @@ -111,18 +122,48 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica ) ); this._setupRefresh(result); + if (this._isBrokerAvailable) { + await this._accountAccess.setAllowedAccess(result.account!, true); + } return result; } removeAccount(account: AccountInfo): Promise { this._globalMemento.update(`lastRemoval:${this._clientId}:${this._authority}`, new Date()); + if (this._isBrokerAvailable) { + return this._accountAccess.setAllowedAccess(account, false); + } return this._pca.getTokenCache().removeAccount(account); } private _registerOnSecretStorageChanged() { + if (this._isBrokerAvailable) { + return this._accountAccess.onDidAccountAccessChange(() => this._update()); + } return this._secretStorageCachePlugin.onDidChange(() => this._update()); } + private _lastSeen = new Map(); + private _verifyIfUsingBroker(result: AuthenticationResult): boolean { + // If we're not brokering, we don't need to verify the date + // the cache check will be sufficient + if (!result.fromNativeBroker) { + return true; + } + const key = result.account!.homeAccountId; + const lastSeen = this._lastSeen.get(key); + const lastTimeAuthed = result.account!.idTokenClaims!.iat!; + if (!lastSeen) { + this._lastSeen.set(key, lastTimeAuthed); + return true; + } + if (lastSeen === lastTimeAuthed) { + return false; + } + this._lastSeen.set(key, lastTimeAuthed); + return true; + } + private async _update() { const before = this._accounts; this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update before: ${before.length}`); @@ -134,7 +175,10 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica this._lastCreated = new Date(); } - const after = await this._pca.getAllAccounts(); + let after = await this._pca.getAllAccounts(); + if (this._isBrokerAvailable) { + after = after.filter(a => this._accountAccess.isAllowedAccess(a)); + } this._accounts = after; this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update after: ${after.length}`); @@ -167,8 +211,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica this._logger.debug(`[_setupRefresh] [${this._clientId}] [${this._authority}] [${scopes.join(' ')}] [${account.username}] timeToRefresh: ${timeToRefresh}`); this._refreshDelayer.trigger( key, - // This may need the redirectUri when we switch to the broker - () => this.acquireTokenSilent({ account, scopes, redirectUri: undefined, forceRefresh: true }), + () => this.acquireTokenSilent({ account, scopes, redirectUri: 'https://vscode.dev/redirect', forceRefresh: true }), timeToRefresh > 0 ? timeToRefresh : 0 ); } diff --git a/extensions/microsoft-authentication/src/node/publicClientCache.ts b/extensions/microsoft-authentication/src/node/publicClientCache.ts index fc6ce38e975..c6f2508e560 100644 --- a/extensions/microsoft-authentication/src/node/publicClientCache.ts +++ b/extensions/microsoft-authentication/src/node/publicClientCache.ts @@ -28,9 +28,9 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient private readonly _globalMemento: Memento, private readonly _secretStorage: SecretStorage, private readonly _logger: LogOutputChannel, - cloudName: string + private readonly _cloudName: string ) { - this._pcasSecretStorage = new PublicClientApplicationsSecretStorage(_secretStorage, cloudName); + this._pcasSecretStorage = new PublicClientApplicationsSecretStorage(_secretStorage, _cloudName); this._disposable = Disposable.from( this._pcasSecretStorage, this._registerSecretStorageHandler(), @@ -111,7 +111,7 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient } private async _doCreatePublicClientApplication(clientId: string, authority: string, pcasKey: string) { - const pca = new CachedPublicClientApplication(clientId, authority, this._globalMemento, this._secretStorage, this._logger); + const pca = new CachedPublicClientApplication(clientId, authority, this._cloudName, this._globalMemento, this._secretStorage, this._logger); this._pcas.set(pcasKey, pca); const disposable = Disposable.from( pca, @@ -160,11 +160,7 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient // Handle the deleted ones for (const pcaKey of this._pcas.keys()) { if (!pcaKeysFromStorage.delete(pcaKey)) { - // This PCA has been removed in another window - this._pcaDisposables.get(pcaKey)?.dispose(); - this._pcaDisposables.delete(pcaKey); - this._pcas.delete(pcaKey); - this._logger.debug(`[_handleSecretStorageChange] Disposed PCA that was deleted in another window: ${pcaKey}`); + this._logger.debug(`[_handleSecretStorageChange] PCA was deleted in another window: ${pcaKey}`); } } diff --git a/extensions/microsoft-authentication/tsconfig.json b/extensions/microsoft-authentication/tsconfig.json index 4b9d06d1847..b40c2eb8716 100644 --- a/extensions/microsoft-authentication/tsconfig.json +++ b/extensions/microsoft-authentication/tsconfig.json @@ -22,6 +22,7 @@ "include": [ "src/**/*", "../../src/vscode-dts/vscode.d.ts", - "../../src/vscode-dts/vscode.proposed.idToken.d.ts" + "../../src/vscode-dts/vscode.proposed.idToken.d.ts", + "../../src/vscode-dts/vscode.proposed.nativeWindowHandle.d.ts" ] } diff --git a/extensions/typescript-language-features/src/typescriptServiceClient.ts b/extensions/typescript-language-features/src/typescriptServiceClient.ts index b5fdb185101..d8ed0b2b284 100644 --- a/extensions/typescript-language-features/src/typescriptServiceClient.ts +++ b/extensions/typescript-language-features/src/typescriptServiceClient.ts @@ -125,7 +125,7 @@ export default class TypeScriptServiceClient extends Disposable implements IType private _isPromptingAfterCrash = false; private isRestarting: boolean = false; private hasServerFatallyCrashedTooManyTimes = false; - private readonly loadingIndicator = this._register(new ServerInitializingIndicator()); + private readonly loadingIndicator: ServerInitializingIndicator; public readonly telemetryReporter: TelemetryReporter; public readonly bufferSyncSupport: BufferSyncSupport; @@ -158,6 +158,8 @@ export default class TypeScriptServiceClient extends Disposable implements IType ) { super(); + this.loadingIndicator = this._register(new ServerInitializingIndicator(this)); + this.logger = services.logger; this.tracer = new Tracer(this.logger); @@ -1254,6 +1256,12 @@ class ServerInitializingIndicator extends Disposable { private _task?: { project: string; resolve: () => void }; + constructor( + private readonly client: ITypeScriptServiceClient, + ) { + super(); + } + public reset(): void { if (this._task) { this._task.resolve(); @@ -1269,15 +1277,28 @@ class ServerInitializingIndicator extends Disposable { // the incoming project loading task is. this.reset(); - const projectDisplayName = vscode.workspace.asRelativePath(projectName); + const projectDisplayName = this.getProjectDisplayName(projectName); + vscode.window.withProgress({ location: vscode.ProgressLocation.Window, - title: vscode.l10n.t("Initializing project '{0}'", projectDisplayName), + title: vscode.l10n.t("Initializing '{0}'", projectDisplayName), }, () => new Promise(resolve => { this._task = { project: projectName, resolve }; })); } + private getProjectDisplayName(projectName: string): string { + const projectUri = this.client.toResource(projectName); + const relPath = vscode.workspace.asRelativePath(projectUri); + + const maxDisplayLength = 60; + if (relPath.length > maxDisplayLength) { + return '...' + relPath.slice(-maxDisplayLength); + } + + return relPath; + } + public startedLoadingFile(fileName: string, task: Promise): void { if (!this._task) { vscode.window.withProgress({ diff --git a/package-lock.json b/package-lock.json index 2150cf7f8c6..8495295338e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,7 +96,7 @@ "cssnano": "^6.0.3", "debounce": "^1.0.0", "deemon": "^1.8.0", - "electron": "32.2.1", + "electron": "32.2.3", "eslint": "^9.11.1", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -5815,9 +5815,9 @@ "dev": true }, "node_modules/electron": { - "version": "32.2.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-32.2.1.tgz", - "integrity": "sha512-GCPI/5hU34pPcNltNpz+uylhhuTm9BM0N8RmrbVgaWBodLSmmcCkvpgN0BseKhO6IwQOPzWaovrcZ/nPIpfGaQ==", + "version": "32.2.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-32.2.3.tgz", + "integrity": "sha512-ClTJrFuwBdZpDNEnVZSV1gTIYSq7c/TYoUv9AmOypL43/xtbfxXkz2vE67ehVoamFobWsIU2by087R5Av8cxJg==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index c187b1c6795..d8b0e933fa6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.96.0", - "distro": "4c96b4c357f6753fff9527bbaf38745be0aee80b", + "distro": "af82ec3330347ea13edd9b1501e3374a5dec8a86", "author": { "name": "Microsoft Corporation" }, @@ -154,7 +154,7 @@ "cssnano": "^6.0.3", "debounce": "^1.0.0", "deemon": "^1.8.0", - "electron": "32.2.1", + "electron": "32.2.3", "eslint": "^9.11.1", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", diff --git a/resources/completions/bash/code b/resources/completions/bash/code index 92b1b4cfcfb..d141c297b96 100644 --- a/resources/completions/bash/code +++ b/resources/completions/bash/code @@ -49,7 +49,7 @@ _@@APPNAME@@() --list-extensions --show-versions --install-extension --uninstall-extension --enable-proposed-api --verbose --log -s --status -p --performance --prof-startup --disable-extensions - --disable-extension --inspect-extensions --update-extensions --download-extension + --disable-extension --inspect-extensions --update-extensions --inspect-brk-extensions --disable-gpu' -- "$cur") ) [[ $COMPREPLY == *= ]] && compopt -o nospace return diff --git a/resources/completions/zsh/_code b/resources/completions/zsh/_code index f49211447ba..eafa37c81c7 100644 --- a/resources/completions/zsh/_code +++ b/resources/completions/zsh/_code @@ -20,9 +20,8 @@ arguments=( '--category[filters installed extension list by category, when using --list-extensions]' '--show-versions[show versions of installed extensions, when using --list-extensions]' '--install-extension[install an extension]:id or path:_files -g "*.vsix(-.)"' - '--uninstall-extension[uninstall an extension]:id' + '--uninstall-extension[uninstall an extension]:id or path:_files -g "*.vsix(-.)"' '--update-extensions[update the installed extensions]' - '--download-extension[download an extension]:id' '--enable-proposed-api[enables proposed API features for extensions]::extension id: ' '--verbose[print verbose output (implies --wait)]' '--log[log level to use]:level [info]:(critical error warn info debug trace off)' diff --git a/src/vs/base/browser/cssValue.ts b/src/vs/base/browser/cssValue.ts index c758b629a71..d30a7747be2 100644 --- a/src/vs/base/browser/cssValue.ts +++ b/src/vs/base/browser/cssValue.ts @@ -2,9 +2,16 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Color } from '../common/color.js'; import { FileAccess } from '../common/network.js'; import { URI } from '../common/uri.js'; +export type CssFragment = string & { readonly __cssFragment: unique symbol }; + +function asFragment(raw: string): CssFragment { + return raw as CssFragment; +} + export function asCssValueWithDefault(cssPropertyValue: string | undefined, dflt: string): string { if (cssPropertyValue !== undefined) { const variableMatch = cssPropertyValue.match(/^\s*var\((.+)\)$/); @@ -20,16 +27,75 @@ export function asCssValueWithDefault(cssPropertyValue: string | undefined, dflt return dflt; } -export function asCSSPropertyValue(value: string) { - return `'${value.replace(/'/g, '%27')}'`; +export function sizeValue(value: string): CssFragment { + const out = value.replaceAll(/[^\w.%+-]/gi, ''); + if (out !== value) { + console.warn(`CSS size ${value} modified to ${out} to be safe for CSS`); + } + return asFragment(out); +} + +export function hexColorValue(value: string): CssFragment { + const out = value.replaceAll(/[^[0-9a-fA-F#]]/gi, ''); + if (out !== value) { + console.warn(`CSS hex color ${value} modified to ${out} to be safe for CSS`); + } + return asFragment(out); +} + +export function identValue(value: string): CssFragment { + const out = value.replaceAll(/[^_\-a-z0-9]/gi, ''); + if (out !== value) { + console.warn(`CSS ident value ${value} modified to ${out} to be safe for CSS`); + } + return asFragment(out); +} + +export function stringValue(value: string): CssFragment { + return asFragment(`'${value.replaceAll(/'/g, '\\000027')}'`); } /** * returns url('...') */ -export function asCSSUrl(uri: URI | null | undefined): string { +export function asCSSUrl(uri: URI | null | undefined): CssFragment { if (!uri) { - return `url('')`; + return asFragment(`url('')`); + } + return inline`url(${stringValue(FileAccess.uriToBrowserUri(uri).toString(true))})`; +} + +export function className(value: string, escapingExcected = false): CssFragment { + const out = CSS.escape(value); + if (!escapingExcected && out !== value) { + console.warn(`CSS class name ${value} modified to ${out} to be safe for CSS`); + } + return asFragment(out); +} + +type InlineCssTemplateValue = CssFragment | Color; + +/** + * Template string tag that that constructs a CSS fragment. + * + * All expressions in the template must be css safe values. + */ +export function inline(strings: TemplateStringsArray, ...values: InlineCssTemplateValue[]): CssFragment { + return asFragment(strings.reduce((result, str, i) => { + const value = values[i] || ''; + return result + str + value; + }, '')); +} + + +export class Builder { + private readonly _parts: CssFragment[] = []; + + push(...parts: CssFragment[]): void { + this._parts.push(...parts); + } + + join(joiner = '\n'): CssFragment { + return asFragment(this._parts.join(joiner)); } - return `url('${FileAccess.uriToBrowserUri(uri).toString(true).replace(/'/g, '%27')}')`; } diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 55b161d6be6..ecf1d194cdc 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -922,105 +922,6 @@ export function getActiveWindow(): CodeWindow { return (document.defaultView?.window ?? mainWindow) as CodeWindow; } -const globalStylesheets = new Map>(); - -export function isGlobalStylesheet(node: Node): boolean { - return globalStylesheets.has(node as HTMLStyleElement); -} - -/** - * A version of createStyleSheet which has a unified API to initialize/set the style content. - */ -export function createStyleSheet2(): WrappedStyleElement { - return new WrappedStyleElement(); -} - -class WrappedStyleElement { - private _currentCssStyle = ''; - private _styleSheet: HTMLStyleElement | undefined = undefined; - - public setStyle(cssStyle: string): void { - if (cssStyle === this._currentCssStyle) { - return; - } - this._currentCssStyle = cssStyle; - - if (!this._styleSheet) { - this._styleSheet = createStyleSheet(mainWindow.document.head, (s) => s.innerText = cssStyle); - } else { - this._styleSheet.innerText = cssStyle; - } - } - - public dispose(): void { - if (this._styleSheet) { - this._styleSheet.remove(); - this._styleSheet = undefined; - } - } -} - -export function createStyleSheet(container: HTMLElement = mainWindow.document.head, beforeAppend?: (style: HTMLStyleElement) => void, disposableStore?: DisposableStore): HTMLStyleElement { - const style = document.createElement('style'); - style.type = 'text/css'; - style.media = 'screen'; - beforeAppend?.(style); - container.appendChild(style); - - if (disposableStore) { - disposableStore.add(toDisposable(() => style.remove())); - } - - // With as container, the stylesheet becomes global and is tracked - // to support auxiliary windows to clone the stylesheet. - if (container === mainWindow.document.head) { - const globalStylesheetClones = new Set(); - globalStylesheets.set(style, globalStylesheetClones); - - for (const { window: targetWindow, disposables } of getWindows()) { - if (targetWindow === mainWindow) { - continue; // main window is already tracked - } - - const cloneDisposable = disposables.add(cloneGlobalStyleSheet(style, globalStylesheetClones, targetWindow)); - disposableStore?.add(cloneDisposable); - } - } - - return style; -} - -export function cloneGlobalStylesheets(targetWindow: Window): IDisposable { - const disposables = new DisposableStore(); - - for (const [globalStylesheet, clonedGlobalStylesheets] of globalStylesheets) { - disposables.add(cloneGlobalStyleSheet(globalStylesheet, clonedGlobalStylesheets, targetWindow)); - } - - return disposables; -} - -function cloneGlobalStyleSheet(globalStylesheet: HTMLStyleElement, globalStylesheetClones: Set, targetWindow: Window): IDisposable { - const disposables = new DisposableStore(); - - const clone = globalStylesheet.cloneNode(true) as HTMLStyleElement; - targetWindow.document.head.appendChild(clone); - disposables.add(toDisposable(() => clone.remove())); - - for (const rule of getDynamicStyleSheetRules(globalStylesheet)) { - clone.sheet?.insertRule(rule.cssText, clone.sheet?.cssRules.length); - } - - disposables.add(sharedMutationObserver.observe(globalStylesheet, disposables, { childList: true })(() => { - clone.textContent = globalStylesheet.textContent; - })); - - globalStylesheetClones.add(clone); - disposables.add(toDisposable(() => globalStylesheetClones.delete(clone))); - - return disposables; -} - interface IMutationObserver { users: number; readonly observer: MutationObserver; @@ -1088,67 +989,6 @@ function createHeadElement(tagName: string, container: HTMLElement = mainWindow. return element; } -let _sharedStyleSheet: HTMLStyleElement | null = null; -function getSharedStyleSheet(): HTMLStyleElement { - if (!_sharedStyleSheet) { - _sharedStyleSheet = createStyleSheet(); - } - return _sharedStyleSheet; -} - -function getDynamicStyleSheetRules(style: HTMLStyleElement) { - if (style?.sheet?.rules) { - // Chrome, IE - return style.sheet.rules; - } - if (style?.sheet?.cssRules) { - // FF - return style.sheet.cssRules; - } - return []; -} - -export function createCSSRule(selector: string, cssText: string, style = getSharedStyleSheet()): void { - if (!style || !cssText) { - return; - } - - style.sheet?.insertRule(`${selector} {${cssText}}`, 0); - - // Apply rule also to all cloned global stylesheets - for (const clonedGlobalStylesheet of globalStylesheets.get(style) ?? []) { - createCSSRule(selector, cssText, clonedGlobalStylesheet); - } -} - -export function removeCSSRulesContainingSelector(ruleName: string, style = getSharedStyleSheet()): void { - if (!style) { - return; - } - - const rules = getDynamicStyleSheetRules(style); - const toDelete: number[] = []; - for (let i = 0; i < rules.length; i++) { - const rule = rules[i]; - if (isCSSStyleRule(rule) && rule.selectorText.indexOf(ruleName) !== -1) { - toDelete.push(i); - } - } - - for (let i = toDelete.length - 1; i >= 0; i--) { - style.sheet?.deleteRule(toDelete[i]); - } - - // Remove rules also from all cloned global stylesheets - for (const clonedGlobalStylesheet of globalStylesheets.get(style) ?? []) { - removeCSSRulesContainingSelector(ruleName, clonedGlobalStylesheet); - } -} - -function isCSSStyleRule(rule: CSSRule): rule is CSSStyleRule { - return typeof (rule as CSSStyleRule).selectorText === 'string'; -} - export function isHTMLElement(e: unknown): e is HTMLElement { // eslint-disable-next-line no-restricted-syntax return e instanceof HTMLElement || e instanceof getWindow(e as Node).HTMLElement; diff --git a/src/vs/base/browser/domObservable.ts b/src/vs/base/browser/domObservable.ts index 94279737983..9ab3f50cde8 100644 --- a/src/vs/base/browser/domObservable.ts +++ b/src/vs/base/browser/domObservable.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createStyleSheet2 } from './dom.js'; +import { createStyleSheet2 } from './domStylesheets.js'; import { DisposableStore, IDisposable } from '../common/lifecycle.js'; import { autorun, IObservable } from '../common/observable.js'; diff --git a/src/vs/base/browser/domStylesheets.ts b/src/vs/base/browser/domStylesheets.ts new file mode 100644 index 00000000000..ebc249c6608 --- /dev/null +++ b/src/vs/base/browser/domStylesheets.ts @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore, toDisposable, IDisposable } from '../common/lifecycle.js'; +import { getWindows, sharedMutationObserver } from './dom.js'; +import { mainWindow } from './window.js'; + +const globalStylesheets = new Map>(); + +export function isGlobalStylesheet(node: Node): boolean { + return globalStylesheets.has(node as HTMLStyleElement); +} + +/** + * A version of createStyleSheet which has a unified API to initialize/set the style content. + */ +export function createStyleSheet2(): WrappedStyleElement { + return new WrappedStyleElement(); +} + +class WrappedStyleElement { + private _currentCssStyle = ''; + private _styleSheet: HTMLStyleElement | undefined = undefined; + + public setStyle(cssStyle: string): void { + if (cssStyle === this._currentCssStyle) { + return; + } + this._currentCssStyle = cssStyle; + + if (!this._styleSheet) { + this._styleSheet = createStyleSheet(mainWindow.document.head, (s) => s.innerText = cssStyle); + } else { + this._styleSheet.innerText = cssStyle; + } + } + + public dispose(): void { + if (this._styleSheet) { + this._styleSheet.remove(); + this._styleSheet = undefined; + } + } +} + +export function createStyleSheet(container: HTMLElement = mainWindow.document.head, beforeAppend?: (style: HTMLStyleElement) => void, disposableStore?: DisposableStore): HTMLStyleElement { + const style = document.createElement('style'); + style.type = 'text/css'; + style.media = 'screen'; + beforeAppend?.(style); + container.appendChild(style); + + if (disposableStore) { + disposableStore.add(toDisposable(() => style.remove())); + } + + // With as container, the stylesheet becomes global and is tracked + // to support auxiliary windows to clone the stylesheet. + if (container === mainWindow.document.head) { + const globalStylesheetClones = new Set(); + globalStylesheets.set(style, globalStylesheetClones); + + for (const { window: targetWindow, disposables } of getWindows()) { + if (targetWindow === mainWindow) { + continue; // main window is already tracked + } + + const cloneDisposable = disposables.add(cloneGlobalStyleSheet(style, globalStylesheetClones, targetWindow)); + disposableStore?.add(cloneDisposable); + } + } + + return style; +} + +export function cloneGlobalStylesheets(targetWindow: Window): IDisposable { + const disposables = new DisposableStore(); + + for (const [globalStylesheet, clonedGlobalStylesheets] of globalStylesheets) { + disposables.add(cloneGlobalStyleSheet(globalStylesheet, clonedGlobalStylesheets, targetWindow)); + } + + return disposables; +} + +function cloneGlobalStyleSheet(globalStylesheet: HTMLStyleElement, globalStylesheetClones: Set, targetWindow: Window): IDisposable { + const disposables = new DisposableStore(); + + const clone = globalStylesheet.cloneNode(true) as HTMLStyleElement; + targetWindow.document.head.appendChild(clone); + disposables.add(toDisposable(() => clone.remove())); + + for (const rule of getDynamicStyleSheetRules(globalStylesheet)) { + clone.sheet?.insertRule(rule.cssText, clone.sheet?.cssRules.length); + } + + disposables.add(sharedMutationObserver.observe(globalStylesheet, disposables, { childList: true })(() => { + clone.textContent = globalStylesheet.textContent; + })); + + globalStylesheetClones.add(clone); + disposables.add(toDisposable(() => globalStylesheetClones.delete(clone))); + + return disposables; +} + +let _sharedStyleSheet: HTMLStyleElement | null = null; +function getSharedStyleSheet(): HTMLStyleElement { + if (!_sharedStyleSheet) { + _sharedStyleSheet = createStyleSheet(); + } + return _sharedStyleSheet; +} + +function getDynamicStyleSheetRules(style: HTMLStyleElement) { + if (style?.sheet?.rules) { + // Chrome, IE + return style.sheet.rules; + } + if (style?.sheet?.cssRules) { + // FF + return style.sheet.cssRules; + } + return []; +} + +export function createCSSRule(selector: string, cssText: string, style = getSharedStyleSheet()): void { + if (!style || !cssText) { + return; + } + + style.sheet?.insertRule(`${selector} {${cssText}}`, 0); + + // Apply rule also to all cloned global stylesheets + for (const clonedGlobalStylesheet of globalStylesheets.get(style) ?? []) { + createCSSRule(selector, cssText, clonedGlobalStylesheet); + } +} + +export function removeCSSRulesContainingSelector(ruleName: string, style = getSharedStyleSheet()): void { + if (!style) { + return; + } + + const rules = getDynamicStyleSheetRules(style); + const toDelete: number[] = []; + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + if (isCSSStyleRule(rule) && rule.selectorText.indexOf(ruleName) !== -1) { + toDelete.push(i); + } + } + + for (let i = toDelete.length - 1; i >= 0; i--) { + style.sheet?.deleteRule(toDelete[i]); + } + + // Remove rules also from all cloned global stylesheets + for (const clonedGlobalStylesheet of globalStylesheets.get(style) ?? []) { + removeCSSRulesContainingSelector(ruleName, clonedGlobalStylesheet); + } +} + +function isCSSStyleRule(rule: CSSRule): rule is CSSStyleRule { + return typeof (rule as CSSStyleRule).selectorText === 'string'; +} diff --git a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts index a1abd1bbc4d..b87e883bd3a 100644 --- a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts +++ b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../dom.js'; +import * as domStylesheetsJs from '../../domStylesheets.js'; import { IMouseEvent } from '../../mouseEvent.js'; import { DomScrollableElement } from '../scrollbar/scrollableElement.js'; import { commonPrefixLength } from '../../../common/arrays.js'; @@ -83,7 +84,7 @@ export class BreadcrumbsWidget { this._disposables.add(dom.addStandardDisposableListener(this._domNode, 'click', e => this._onClick(e))); container.appendChild(this._scrollable.getDomNode()); - const styleElement = dom.createStyleSheet(this._domNode); + const styleElement = domStylesheetsJs.createStyleSheet(this._domNode); this._style(styleElement, styles); const focusTracker = dom.trackFocus(this._domNode); diff --git a/src/vs/base/browser/ui/findinput/findInput.ts b/src/vs/base/browser/ui/findinput/findInput.ts index 481c62b4c28..7546053dd64 100644 --- a/src/vs/base/browser/ui/findinput/findInput.ts +++ b/src/vs/base/browser/ui/findinput/findInput.ts @@ -17,6 +17,7 @@ import './findInput.css'; import * as nls from '../../../../nls.js'; import { DisposableStore, MutableDisposable } from '../../../common/lifecycle.js'; import { createInstantHoverDelegate } from '../hover/hoverDelegateFactory.js'; +import { IHistory } from '../../../common/history.js'; export interface IFindInputOptions { @@ -32,11 +33,11 @@ export interface IFindInputOptions { readonly appendCaseSensitiveLabel?: string; readonly appendWholeWordsLabel?: string; readonly appendRegexLabel?: string; - readonly history?: string[]; readonly additionalToggles?: Toggle[]; readonly showHistoryHint?: () => boolean; readonly toggleStyles: IToggleStyles; readonly inputBoxStyles: IInputBoxStyles; + readonly history?: IHistory; } const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); @@ -92,7 +93,6 @@ export class FindInput extends Widget { const appendCaseSensitiveLabel = options.appendCaseSensitiveLabel || ''; const appendWholeWordsLabel = options.appendWholeWordsLabel || ''; const appendRegexLabel = options.appendRegexLabel || ''; - const history = options.history || []; const flexibleHeight = !!options.flexibleHeight; const flexibleWidth = !!options.flexibleWidth; const flexibleMaxHeight = options.flexibleMaxHeight; @@ -106,12 +106,12 @@ export class FindInput extends Widget { validationOptions: { validation: this.validation }, - history, showHistoryHint: options.showHistoryHint, flexibleHeight, flexibleWidth, flexibleMaxHeight, inputBoxStyles: options.inputBoxStyles, + history: options.history })); const hoverDelegate = this._register(createInstantHoverDelegate()); diff --git a/src/vs/base/browser/ui/findinput/replaceInput.ts b/src/vs/base/browser/ui/findinput/replaceInput.ts index e608b5f2923..22a81e211c9 100644 --- a/src/vs/base/browser/ui/findinput/replaceInput.ts +++ b/src/vs/base/browser/ui/findinput/replaceInput.ts @@ -108,7 +108,7 @@ export class ReplaceInput extends Widget { validationOptions: { validation: this.validation }, - history, + history: new Set(history), showHistoryHint: options.showHistoryHint, flexibleHeight, flexibleWidth, diff --git a/src/vs/base/browser/ui/inputbox/inputBox.ts b/src/vs/base/browser/ui/inputbox/inputBox.ts index 97262fce411..2f427923269 100644 --- a/src/vs/base/browser/ui/inputbox/inputBox.ts +++ b/src/vs/base/browser/ui/inputbox/inputBox.ts @@ -17,7 +17,7 @@ import { ScrollableElement } from '../scrollbar/scrollableElement.js'; import { Widget } from '../widget.js'; import { IAction } from '../../../common/actions.js'; import { Emitter, Event } from '../../../common/event.js'; -import { HistoryNavigator } from '../../../common/history.js'; +import { HistoryNavigator, IHistory } from '../../../common/history.js'; import { equals } from '../../../common/objects.js'; import { ScrollbarVisibility } from '../../../common/scrollable.js'; import './inputBox.css'; @@ -39,6 +39,7 @@ export interface IInputOptions { readonly flexibleMaxHeight?: number; readonly actions?: ReadonlyArray; readonly inputBoxStyles: IInputBoxStyles; + readonly history?: IHistory; } export interface IInputBoxStyles { @@ -624,7 +625,6 @@ export class InputBox extends Widget { } export interface IHistoryInputOptions extends IInputOptions { - history: string[]; readonly showHistoryHint?: () => boolean; } diff --git a/src/vs/base/browser/ui/list/listWidget.ts b/src/vs/base/browser/ui/list/listWidget.ts index 2f4826648b8..ae9d5a46fb4 100644 --- a/src/vs/base/browser/ui/list/listWidget.ts +++ b/src/vs/base/browser/ui/list/listWidget.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { IDragAndDropData } from '../../dnd.js'; -import { createStyleSheet, Dimension, EventHelper, getActiveElement, getWindow, isActiveElement, isEditableElement, isHTMLElement, isMouseEvent } from '../../dom.js'; +import { Dimension, EventHelper, getActiveElement, getWindow, isActiveElement, isEditableElement, isHTMLElement, isMouseEvent } from '../../dom.js'; +import { createStyleSheet } from '../../domStylesheets.js'; import { asCssValueWithDefault } from '../../cssValue.js'; import { DomEmitter } from '../../event.js'; import { IKeyboardEvent, StandardKeyboardEvent } from '../../keyboardEvent.js'; diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index d05498fb93a..eefff7f8b48 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -5,7 +5,8 @@ import { isFirefox } from '../../browser.js'; import { EventType as TouchEventType, Gesture } from '../../touch.js'; -import { $, addDisposableListener, append, clearNode, createStyleSheet, Dimension, EventHelper, EventLike, EventType, getActiveElement, getWindow, IDomNodePagePosition, isAncestor, isInShadowDOM } from '../../dom.js'; +import { $, addDisposableListener, append, clearNode, Dimension, EventHelper, EventLike, EventType, getActiveElement, getWindow, IDomNodePagePosition, isAncestor, isInShadowDOM } from '../../dom.js'; +import { createStyleSheet } from '../../domStylesheets.js'; import { StandardKeyboardEvent } from '../../keyboardEvent.js'; import { StandardMouseEvent } from '../../mouseEvent.js'; import { ActionBar, ActionsOrientation, IActionViewItemProvider } from '../actionbar/actionbar.js'; diff --git a/src/vs/base/browser/ui/menu/menubar.ts b/src/vs/base/browser/ui/menu/menubar.ts index 3acf246b40c..aa1a58b9c5e 100644 --- a/src/vs/base/browser/ui/menu/menubar.ts +++ b/src/vs/base/browser/ui/menu/menubar.ts @@ -741,6 +741,12 @@ export class MenuBar extends Disposable { } if (this.focusedMenu) { + // When the menu is toggled on, it may be in compact state and trying to + // focus the first menu. In this case we should focus the overflow instead. + if (this.focusedMenu.index === 0 && this.numMenusShown === 0) { + this.focusedMenu.index = MenuBar.OVERFLOW_INDEX; + } + if (this.focusedMenu.index === MenuBar.OVERFLOW_INDEX) { this.overflowMenu.buttonElement.focus(); } else { diff --git a/src/vs/base/browser/ui/sash/sash.ts b/src/vs/base/browser/ui/sash/sash.ts index 8596d0cd8d9..8f51c7068c4 100644 --- a/src/vs/base/browser/ui/sash/sash.ts +++ b/src/vs/base/browser/ui/sash/sash.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, append, createStyleSheet, EventHelper, EventLike, getWindow, isHTMLElement } from '../../dom.js'; +import { $, append, EventHelper, EventLike, getWindow, isHTMLElement } from '../../dom.js'; +import { createStyleSheet } from '../../domStylesheets.js'; import { DomEmitter } from '../../event.js'; import { EventType, Gesture } from '../../touch.js'; import { Delayer } from '../../../common/async.js'; diff --git a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts index 24341bab33c..9ccbafa594e 100644 --- a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts +++ b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../dom.js'; +import * as domStylesheetsJs from '../../domStylesheets.js'; import * as cssJs from '../../cssValue.js'; import { DomEmitter } from '../../event.js'; import { IContentActionHandler } from '../../formattedTextRenderer.js'; @@ -192,7 +193,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi this._dropDownPosition = AnchorPosition.BELOW; // Inline stylesheet for themes - this.styleElement = dom.createStyleSheet(this.selectDropDownContainer); + this.styleElement = domStylesheetsJs.createStyleSheet(this.selectDropDownContainer); // Prevent dragging of dropdown #114329 this.selectDropDownContainer.setAttribute('draggable', 'true'); diff --git a/src/vs/base/browser/ui/table/tableWidget.ts b/src/vs/base/browser/ui/table/tableWidget.ts index 3e2142482f0..f352a926d13 100644 --- a/src/vs/base/browser/ui/table/tableWidget.ts +++ b/src/vs/base/browser/ui/table/tableWidget.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, append, clearNode, createStyleSheet, getContentHeight, getContentWidth } from '../../dom.js'; +import { $, append, clearNode, getContentHeight, getContentWidth } from '../../dom.js'; +import { createStyleSheet } from '../../domStylesheets.js'; import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js'; import { getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js'; import { IListRenderer, IListVirtualDelegate } from '../list/list.js'; diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 8d1139ec9ef..be65784fd80 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { IDragAndDropData } from '../../dnd.js'; -import { $, append, clearNode, createStyleSheet, h, hasParentWithClass, isActiveElement, isKeyboardEvent, addDisposableListener, isEditableElement } from '../../dom.js'; +import { $, append, clearNode, h, hasParentWithClass, isActiveElement, isKeyboardEvent, addDisposableListener, isEditableElement } from '../../dom.js'; +import { createStyleSheet } from '../../domStylesheets.js'; import { asCssValueWithDefault } from '../../cssValue.js'; import { DomEmitter } from '../../event.js'; import { StandardKeyboardEvent } from '../../keyboardEvent.js'; @@ -839,6 +840,7 @@ class FindWidget extends Disposable { this.toggles = toggleContributions.map(contribution => this._register(new TreeFindToggle(contribution, styles.toggleStyles, toggleHoverDelegate))); this.onDidToggleChange = Event.any(...this.toggles.map(toggle => Event.map(toggle.onChange, () => ({ id: toggle.id, isChecked: toggle.checked })))); + const history = options?.history || []; this.findInput = this._register(new FindInput(this.elements.findInput, contextViewProvider, { label: localize('type to search', "Type to search"), placeholder, @@ -846,7 +848,7 @@ class FindWidget extends Disposable { showCommonFindToggles: false, inputBoxStyles: styles.inputBoxStyles, toggleStyles: styles.toggleStyles, - history: options?.history + history: new Set(history) })); this.actionbar = this._register(new ActionBar(this.elements.actionbar)); diff --git a/src/vs/base/common/history.ts b/src/vs/base/common/history.ts index 39413a67d56..ed2a5e5232d 100644 --- a/src/vs/base/common/history.ts +++ b/src/vs/base/common/history.ts @@ -6,14 +6,23 @@ import { SetWithKey } from './collections.js'; import { ArrayNavigator, INavigator } from './navigator.js'; -export class HistoryNavigator implements INavigator { +export interface IHistory { + delete(t: T): boolean; + add(t: T): this; + has(t: T): boolean; + clear(): void; + forEach(callbackfn: (value: T, value2: T, set: Set) => void, thisArg?: any): void; + replace?(t: T[]): void; +} - private _history!: Set; +export class HistoryNavigator implements INavigator { private _limit: number; private _navigator!: ArrayNavigator; - constructor(history: readonly T[] = [], limit: number = 10) { - this._initialize(history); + constructor( + private _history: IHistory = new Set(), + limit: number = 10, + ) { this._limit = limit; this._onChange(); } @@ -69,7 +78,7 @@ export class HistoryNavigator implements INavigator { } public clear(): void { - this._initialize([]); + this._history.clear(); this._onChange(); } @@ -82,7 +91,12 @@ export class HistoryNavigator implements INavigator { private _reduceToLimit() { const data = this._elements; if (data.length > this._limit) { - this._initialize(data.slice(data.length - this._limit)); + const replaceValue = data.slice(data.length - this._limit); + if (this._history.replace) { + this._history.replace(replaceValue); + } else { + this._history = new Set(replaceValue); + } } } @@ -95,13 +109,6 @@ export class HistoryNavigator implements INavigator { return this._elements.indexOf(currentElement); } - private _initialize(history: readonly T[]): void { - this._history = new Set(); - for (const entry of history) { - this._history.add(entry); - } - } - private get _elements(): T[] { const elements: T[] = []; this._history.forEach(e => elements.push(e)); diff --git a/src/vs/base/test/common/history.test.ts b/src/vs/base/test/common/history.test.ts index c836f610c2b..e75463cab89 100644 --- a/src/vs/base/test/common/history.test.ts +++ b/src/vs/base/test/common/history.test.ts @@ -11,13 +11,13 @@ suite('History Navigator', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('create reduces the input to limit', () => { - const testObject = new HistoryNavigator(['1', '2', '3', '4'], 2); + const testObject = new HistoryNavigator(new Set(['1', '2', '3', '4']), 2); assert.deepStrictEqual(['3', '4'], toArray(testObject)); }); test('create sets the position after last', () => { - const testObject = new HistoryNavigator(['1', '2', '3', '4'], 100); + const testObject = new HistoryNavigator(new Set(['1', '2', '3', '4']), 100); assert.strictEqual(testObject.current(), null); assert.strictEqual(testObject.isNowhere(), true); @@ -31,7 +31,7 @@ suite('History Navigator', () => { }); test('last returns last element', () => { - const testObject = new HistoryNavigator(['1', '2', '3', '4'], 100); + const testObject = new HistoryNavigator(new Set(['1', '2', '3', '4']), 100); assert.strictEqual(testObject.first(), '1'); assert.strictEqual(testObject.last(), '4'); @@ -40,7 +40,7 @@ suite('History Navigator', () => { }); test('first returns first element', () => { - const testObject = new HistoryNavigator(['1', '2', '3', '4'], 3); + const testObject = new HistoryNavigator(new Set(['1', '2', '3', '4']), 3); assert.strictEqual('2', testObject.first()); assert.strictEqual(testObject.isFirst(), true); @@ -48,7 +48,7 @@ suite('History Navigator', () => { }); test('next returns next element', () => { - const testObject = new HistoryNavigator(['1', '2', '3', '4'], 3); + const testObject = new HistoryNavigator(new Set(['1', '2', '3', '4']), 3); testObject.first(); @@ -58,7 +58,7 @@ suite('History Navigator', () => { }); test('previous returns previous element', () => { - const testObject = new HistoryNavigator(['1', '2', '3', '4'], 3); + const testObject = new HistoryNavigator(new Set(['1', '2', '3', '4']), 3); assert.strictEqual(testObject.previous(), '4'); assert.strictEqual(testObject.previous(), '3'); @@ -67,7 +67,7 @@ suite('History Navigator', () => { }); test('next on last element returns null and remains on last', () => { - const testObject = new HistoryNavigator(['1', '2', '3', '4'], 3); + const testObject = new HistoryNavigator(new Set(['1', '2', '3', '4']), 3); testObject.first(); testObject.last(); @@ -79,7 +79,7 @@ suite('History Navigator', () => { }); test('previous on first element returns null and remains on first', () => { - const testObject = new HistoryNavigator(['1', '2', '3', '4'], 3); + const testObject = new HistoryNavigator(new Set(['1', '2', '3', '4']), 3); testObject.first(); @@ -90,7 +90,7 @@ suite('History Navigator', () => { }); test('add reduces the input to limit', () => { - const testObject = new HistoryNavigator(['1', '2', '3', '4'], 2); + const testObject = new HistoryNavigator(new Set(['1', '2', '3', '4']), 2); testObject.add('5'); @@ -98,7 +98,7 @@ suite('History Navigator', () => { }); test('adding existing element changes the position', () => { - const testObject = new HistoryNavigator(['1', '2', '3', '4'], 5); + const testObject = new HistoryNavigator(new Set(['1', '2', '3', '4']), 5); testObject.add('2'); @@ -106,7 +106,7 @@ suite('History Navigator', () => { }); test('add resets the navigator to last', () => { - const testObject = new HistoryNavigator(['1', '2', '3', '4'], 3); + const testObject = new HistoryNavigator(new Set(['1', '2', '3', '4']), 3); testObject.first(); testObject.add('5'); @@ -118,7 +118,7 @@ suite('History Navigator', () => { }); test('adding an existing item changes the order', () => { - const testObject = new HistoryNavigator(['1', '2', '3']); + const testObject = new HistoryNavigator(new Set(['1', '2', '3'])); testObject.add('1'); @@ -126,7 +126,7 @@ suite('History Navigator', () => { }); test('previous returns null if the current position is the first one', () => { - const testObject = new HistoryNavigator(['1', '2', '3']); + const testObject = new HistoryNavigator(new Set(['1', '2', '3'])); testObject.first(); @@ -135,7 +135,7 @@ suite('History Navigator', () => { }); test('previous returns object if the current position is not the first one', () => { - const testObject = new HistoryNavigator(['1', '2', '3']); + const testObject = new HistoryNavigator(new Set(['1', '2', '3'])); testObject.first(); testObject.next(); @@ -144,7 +144,7 @@ suite('History Navigator', () => { }); test('next returns null if the current position is the last one', () => { - const testObject = new HistoryNavigator(['1', '2', '3']); + const testObject = new HistoryNavigator(new Set(['1', '2', '3'])); testObject.last(); @@ -154,7 +154,7 @@ suite('History Navigator', () => { }); test('next returns object if the current position is not the last one', () => { - const testObject = new HistoryNavigator(['1', '2', '3']); + const testObject = new HistoryNavigator(new Set(['1', '2', '3'])); testObject.last(); testObject.previous(); @@ -163,7 +163,7 @@ suite('History Navigator', () => { }); test('clear', () => { - const testObject = new HistoryNavigator(['a', 'b', 'c']); + const testObject = new HistoryNavigator(new Set(['a', 'b', 'c'])); assert.strictEqual(testObject.previous(), 'c'); testObject.clear(); assert.strictEqual(testObject.current(), null); diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index d8880a9b34f..11ca66e8767 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -622,7 +622,14 @@ export class CodeApplication extends Disposable { // Set lifecycle phase to `Eventually` after a short delay and when idle (min 2.5sec, max 5sec) const eventuallyPhaseScheduler = this._register(new RunOnceScheduler(() => { - this._register(runWhenGlobalIdle(() => this.lifecycleMainService.phase = LifecycleMainPhase.Eventually, 2500)); + this._register(runWhenGlobalIdle(() => { + + // Signal phase: eventually + this.lifecycleMainService.phase = LifecycleMainPhase.Eventually; + + // Eventually Post Open Window Tasks + this.eventuallyAfterWindowOpen(); + }, 2500)); }, 2500)); eventuallyPhaseScheduler.schedule(); } @@ -1373,9 +1380,6 @@ export class CodeApplication extends Disposable { if (isMacintosh && app.runningUnderARM64Translation) { this.windowsMainService?.sendToFocused('vscode:showTranslatedBuildWarning'); } - - // Validate Device ID is up to date - validatedevDeviceId(this.stateService, this.logService); } private async installMutex(): Promise { @@ -1451,4 +1455,11 @@ export class CodeApplication extends Disposable { this.windowsMainService?.sendToFocused('vscode:showArgvParseWarning'); } } + + private eventuallyAfterWindowOpen(): void { + + // Validate Device ID is up to date (delay this as it has shown significant perf impact) + // Refs: https://github.com/microsoft/vscode/issues/234064 + validatedevDeviceId(this.stateService, this.logService); + } } diff --git a/src/vs/code/electron-sandbox/processExplorer/processExplorerMain.ts b/src/vs/code/electron-sandbox/processExplorer/processExplorerMain.ts index 13901ed9264..616165dc977 100644 --- a/src/vs/code/electron-sandbox/processExplorer/processExplorerMain.ts +++ b/src/vs/code/electron-sandbox/processExplorer/processExplorerMain.ts @@ -6,7 +6,8 @@ import './media/processExplorer.css'; import '../../../base/browser/ui/codicons/codiconStyles.js'; // make sure codicon css is loaded import { localize } from '../../../nls.js'; -import { $, append, createStyleSheet } from '../../../base/browser/dom.js'; +import { $, append } from '../../../base/browser/dom.js'; +import { createStyleSheet } from '../../../base/browser/domStylesheets.js'; import { IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; import { DataTree } from '../../../base/browser/ui/tree/dataTree.js'; import { IDataSource, ITreeNode, ITreeRenderer } from '../../../base/browser/ui/tree/tree.js'; diff --git a/src/vs/code/electron-utility/sharedProcess/contrib/defaultExtensionsInitializer.ts b/src/vs/code/electron-utility/sharedProcess/contrib/defaultExtensionsInitializer.ts new file mode 100644 index 00000000000..69f0511553e --- /dev/null +++ b/src/vs/code/electron-utility/sharedProcess/contrib/defaultExtensionsInitializer.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { dirname, join } from 'path'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { isWindows } from '../../../../base/common/platform.js'; +import { URI } from '../../../../base/common/uri.js'; +import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { INativeServerExtensionManagementService } from '../../../../platform/extensionManagement/node/extensionManagementService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { FileOperationResult, IFileService, IFileStat, toFileOperationResult } from '../../../../platform/files/common/files.js'; +import { getErrorMessage } from '../../../../base/common/errors.js'; + +const defaultExtensionsInitStatusKey = 'initializing-default-extensions'; + +export class DefaultExtensionsInitializer extends Disposable { + constructor( + @INativeEnvironmentService private readonly environmentService: INativeEnvironmentService, + @INativeServerExtensionManagementService private readonly extensionManagementService: INativeServerExtensionManagementService, + @IStorageService storageService: IStorageService, + @IFileService private readonly fileService: IFileService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + if (isWindows && storageService.getBoolean(defaultExtensionsInitStatusKey, StorageScope.APPLICATION, true)) { + storageService.store(defaultExtensionsInitStatusKey, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + this.initializeDefaultExtensions().then(() => storageService.store(defaultExtensionsInitStatusKey, false, StorageScope.APPLICATION, StorageTarget.MACHINE)); + } + } + + private async initializeDefaultExtensions(): Promise { + const extensionsLocation = this.getDefaultExtensionVSIXsLocation(); + let stat: IFileStat; + try { + stat = await this.fileService.resolve(extensionsLocation); + if (!stat.children) { + this.logService.debug('There are no default extensions to initialize', extensionsLocation.toString()); + return; + } + } catch (error) { + if (toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + this.logService.debug('There are no default extensions to initialize', extensionsLocation.toString()); + return; + } + this.logService.error('Error initializing extensions', error); + return; + } + + const vsixs = stat.children.filter(child => child.name.endsWith('.vsix')); + if (vsixs.length === 0) { + this.logService.debug('There are no default extensions to initialize', extensionsLocation.toString()); + return; + } + + this.logService.info('Initializing default extensions', extensionsLocation.toString()); + await Promise.all(vsixs.map(async vsix => { + this.logService.info('Installing default extension', vsix.resource.toString()); + try { + await this.extensionManagementService.install(vsix.resource, { donotIncludePackAndDependencies: true, keepExisting: false }); + this.logService.info('Default extension installed', vsix.resource.toString()); + } catch (error) { + this.logService.error('Error installing default extension', vsix.resource.toString(), getErrorMessage(error)); + } + })); + this.logService.info('Default extensions initialized', extensionsLocation.toString()); + } + + private getDefaultExtensionVSIXsLocation(): URI { + // appRoot = C:\Users\\AppData\Local\Programs\Microsoft VS Code Insiders\resources\app + // extensionsPath = C:\Users\\AppData\Local\Programs\Microsoft VS Code Insiders\bootstrap\extensions + return URI.file(join(dirname(dirname(this.environmentService.appRoot)), 'bootstrap', 'extensions')); + } + +} diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 2a99598a73d..00a0b29125a 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -118,6 +118,7 @@ import { getOSReleaseInfo } from '../../../base/node/osReleaseInfo.js'; import { getDesktopEnvironment } from '../../../base/common/desktopEnvironmentInfo.js'; import { getCodeDisplayProtocol, getDisplayProtocol } from '../../../base/node/osDisplayProtocolInfo.js'; import { RequestService } from '../../../platform/request/electron-utility/requestService.js'; +import { DefaultExtensionsInitializer } from './contrib/defaultExtensionsInitializer.js'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -187,7 +188,8 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { instantiationService.createInstance(LogsDataCleaner), instantiationService.createInstance(LocalizationsUpdater), instantiationService.createInstance(ExtensionsContributions), - instantiationService.createInstance(UserDataProfilesCleaner) + instantiationService.createInstance(UserDataProfilesCleaner), + instantiationService.createInstance(DefaultExtensionsInitializer) )); } diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index 39033f27227..e91bea07d6a 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -33,7 +33,6 @@ function shouldSpawnCliProcess(argv: NativeParsedArgs): boolean { return !!argv['install-source'] || !!argv['list-extensions'] || !!argv['install-extension'] - || !!argv['download-extension'] || !!argv['uninstall-extension'] || !!argv['update-extensions'] || !!argv['locate-extension'] diff --git a/src/vs/code/node/cliProcessMain.ts b/src/vs/code/node/cliProcessMain.ts index bbca94ab07c..d3b20ecea47 100644 --- a/src/vs/code/node/cliProcessMain.ts +++ b/src/vs/code/node/cliProcessMain.ts @@ -282,14 +282,6 @@ class CliMain extends Disposable { return instantiationService.createInstance(ExtensionManagementCLI, new ConsoleLogger(LogLevel.Info, false)).listExtensions(!!this.argv['show-versions'], this.argv['category'], profileLocation); } - // Download Extensions - else if (this.argv['download-extension']) { - if (!this.argv['location']) { - throw new Error('The location argument is required to download an extension.'); - } - return instantiationService.createInstance(ExtensionManagementCLI, new ConsoleLogger(LogLevel.Info, false)).downloadExtensions(this.argv['download-extension'], URI.parse(this.argv['location'])); - } - // Install Extension else if (this.argv['install-extension'] || this.argv['install-builtin-extension']) { const installOptions: InstallOptions = { isMachineScoped: !!this.argv['do-not-sync'], installPreReleaseVersion: !!this.argv['pre-release'], profileLocation }; diff --git a/src/vs/editor/browser/editorDom.ts b/src/vs/editor/browser/editorDom.ts index 75bd4acfae7..38100ab8407 100644 --- a/src/vs/editor/browser/editorDom.ts +++ b/src/vs/editor/browser/editorDom.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../base/browser/dom.js'; +import * as domStylesheetsJs from '../../base/browser/domStylesheets.js'; import { GlobalPointerMoveMonitor } from '../../base/browser/globalPointerMoveMonitor.js'; import { StandardMouseEvent } from '../../base/browser/mouseEvent.js'; import { RunOnceScheduler } from '../../base/common/async.js'; @@ -368,7 +369,7 @@ class RefCountedCssRule { public readonly properties: CssProperties, ) { this._styleElementDisposables = new DisposableStore(); - this._styleElement = dom.createStyleSheet(_containerElement, undefined, this._styleElementDisposables); + this._styleElement = domStylesheetsJs.createStyleSheet(_containerElement, undefined, this._styleElementDisposables); this._styleElement.textContent = this.getCssText(this.className, this.properties); } diff --git a/src/vs/editor/browser/gpu/atlas/textureAtlasSlabAllocator.ts b/src/vs/editor/browser/gpu/atlas/textureAtlasSlabAllocator.ts index b41fed6978c..461fd507dbe 100644 --- a/src/vs/editor/browser/gpu/atlas/textureAtlasSlabAllocator.ts +++ b/src/vs/editor/browser/gpu/atlas/textureAtlasSlabAllocator.ts @@ -55,7 +55,7 @@ export class TextureAtlasSlabAllocator implements ITextureAtlasAllocator { })); this._slabW = Math.min( - options?.slabW ?? (64 << (Math.floor(getActiveWindow().devicePixelRatio) - 1)), + options?.slabW ?? (64 << Math.max(Math.floor(getActiveWindow().devicePixelRatio) - 1, 0)), this._canvas.width ); this._slabH = Math.min( diff --git a/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts b/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts index 0caae56750d..89707be51d4 100644 --- a/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts +++ b/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts @@ -5,11 +5,11 @@ import { getActiveWindow } from '../../../base/browser/dom.js'; import { BugIndicatingError } from '../../../base/common/errors.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; import { EditorOption } from '../../common/config/editorOptions.js'; import { CursorColumns } from '../../common/core/cursorColumns.js'; import type { IViewLineTokens } from '../../common/tokens/lineTokens.js'; -import type { ViewLinesDeletedEvent } from '../../common/viewEvents.js'; +import { ViewEventHandler } from '../../common/viewEventHandler.js'; +import type { ViewConfigurationChangedEvent, ViewLinesChangedEvent, ViewLinesDeletedEvent, ViewLinesInsertedEvent, ViewScrollChangedEvent, ViewTokensChangedEvent } from '../../common/viewEvents.js'; import type { ViewportData } from '../../common/viewLayout/viewLinesViewportData.js'; import type { ViewLineRenderingData } from '../../common/viewModel.js'; import type { ViewContext } from '../../common/viewModel/viewContext.js'; @@ -38,7 +38,7 @@ const enum CellBufferInfo { TextureIndex = 5, } -export class FullFileRenderStrategy extends Disposable implements IGpuRenderStrategy { +export class FullFileRenderStrategy extends ViewEventHandler implements IGpuRenderStrategy { readonly wgsl: string = fullFileRenderStrategyWgsl; @@ -57,8 +57,9 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra private _visibleObjectCount: number = 0; private _finalRenderedLine: number = 0; - private _scrollOffsetBindBuffer!: GPUBuffer; - private _scrollOffsetValueBuffers!: [Float32Array, Float32Array]; + private _scrollOffsetBindBuffer: GPUBuffer; + private _scrollOffsetValueBuffer: Float32Array; + private _scrollInitialized: boolean = false; private readonly _queuedBufferUpdates: [ViewLinesDeletedEvent[], ViewLinesDeletedEvent[]] = [[], []]; @@ -76,6 +77,8 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra ) { super(); + this._context.addEventHandler(this); + // TODO: Detect when lines have been tokenized and clear _upToDateLines const fontFamily = this._context.configuration.options.get(EditorOption.fontFamily); const fontSize = this._context.configuration.options.get(EditorOption.fontSize); @@ -99,12 +102,84 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra size: scrollOffsetBufferSize * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, })).object; - this._scrollOffsetValueBuffers = [ - new Float32Array(scrollOffsetBufferSize), - new Float32Array(scrollOffsetBufferSize), - ]; + this._scrollOffsetValueBuffer = new Float32Array(scrollOffsetBufferSize); } + // #region Event handlers + + public override onConfigurationChanged(e: ViewConfigurationChangedEvent): boolean { + this._upToDateLines[0].clear(); + this._upToDateLines[1].clear(); + return true; + } + + public override onTokensChanged(e: ViewTokensChangedEvent): boolean { + // TODO: This currently fires for the entire viewport whenever scrolling stops + // https://github.com/microsoft/vscode/issues/233942 + for (const range of e.ranges) { + for (let i = range.fromLineNumber; i <= range.toLineNumber; i++) { + this._upToDateLines[0].delete(i); + this._upToDateLines[1].delete(i); + } + } + return true; + } + + public override onLinesDeleted(e: ViewLinesDeletedEvent): boolean { + // TODO: This currently invalidates everything after the deleted line, it could shift the + // line data up to retain some up to date lines + // TODO: This does not invalidate lines that are no longer in the file + for (const i of [0, 1]) { + const upToDateLines = this._upToDateLines[i]; + const lines = Array.from(upToDateLines); + for (const upToDateLine of lines) { + if (upToDateLine > e.fromLineNumber) { + upToDateLines.delete(upToDateLine); + } + } + } + + // Queue updates that need to happen on the active buffer, not just the cache. This is + // deferred since the active buffer could be locked by the GPU which would block the main + // thread. + this._queueBufferUpdate(e); + + return true; + } + + public override onLinesInserted(e: ViewLinesInsertedEvent): boolean { + // TODO: This currently invalidates everything after the deleted line, it could shift the + // line data up to retain some up to date lines + for (const i of [0, 1]) { + const upToDateLines = this._upToDateLines[i]; + const lines = Array.from(upToDateLines); + for (const upToDateLine of lines) { + if (upToDateLine > e.fromLineNumber) { + upToDateLines.delete(upToDateLine); + } + } + } + return true; + } + + public override onLinesChanged(e: ViewLinesChangedEvent): boolean { + for (let i = e.fromLineNumber; i < e.fromLineNumber + e.count; i++) { + this._upToDateLines[0].delete(i); + this._upToDateLines[1].delete(i); + } + return true; + } + + public override onScrollChanged(e?: ViewScrollChangedEvent): boolean { + const dpr = getActiveWindow().devicePixelRatio; + this._scrollOffsetValueBuffer[0] = (e?.scrollLeft ?? this._context.viewLayout.getCurrentScrollLeft()) * dpr; + this._scrollOffsetValueBuffer[1] = (e?.scrollTop ?? this._context.viewLayout.getCurrentScrollTop()) * dpr; + this._device.queue.writeBuffer(this._scrollOffsetBindBuffer, 0, this._scrollOffsetValueBuffer); + return true; + } + + // #endregion + reset() { for (const bufferIndex of [0, 1]) { // Zero out buffer and upload to GPU to prevent stale rows from rendering @@ -122,12 +197,8 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra let chars = ''; let y = 0; let x = 0; - let screenAbsoluteX = 0; - let screenAbsoluteY = 0; - let zeroToOneX = 0; - let zeroToOneY = 0; - let wgslX = 0; - let wgslY = 0; + let absoluteOffsetX = 0; + let absoluteOffsetY = 0; let xOffset = 0; let glyph: Readonly; let cellIndex = 0; @@ -145,11 +216,10 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra const dpr = getActiveWindow().devicePixelRatio; - // Update scroll offset - const scrollOffsetBuffer = this._scrollOffsetValueBuffers[this._activeDoubleBufferIndex]; - scrollOffsetBuffer[0] = this._context.viewLayout.getCurrentScrollLeft() * dpr; - scrollOffsetBuffer[1] = this._context.viewLayout.getCurrentScrollTop() * dpr; - this._device.queue.writeBuffer(this._scrollOffsetBindBuffer, 0, scrollOffsetBuffer); + if (!this._scrollInitialized) { + this.onScrollChanged(); + this._scrollInitialized = true; + } // Update cell data const cellBuffer = new Float32Array(this._cellValueBuffers[this._activeDoubleBufferIndex]); @@ -189,10 +259,10 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra continue; } - // TODO: Update on dirty lines; is this known by line before rendering? - // if (upToDateLines.has(y)) { - // continue; - // } + // Skip updating the line if it's already up to date + if (upToDateLines.has(y)) { + continue; + } dirtyLineStart = Math.min(dirtyLineStart, y); dirtyLineEnd = Math.max(dirtyLineEnd, y); @@ -200,29 +270,6 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra content = lineData.content; xOffset = 0; - // See ViewLine#renderLine - // const renderLineInput = new RenderLineInput( - // options.useMonospaceOptimizations, - // options.canUseHalfwidthRightwardsArrow, - // lineData.content, - // lineData.continuesWithWrappedLine, - // lineData.isBasicASCII, - // lineData.containsRTL, - // lineData.minColumn - 1, - // lineData.tokens, - // actualInlineDecorations, - // lineData.tabSize, - // lineData.startVisibleColumn, - // options.spaceWidth, - // options.middotWidth, - // options.wsmiddotWidth, - // options.stopRenderingLineAfter, - // options.renderWhitespace, - // options.renderControlCharacters, - // options.fontLigatures !== EditorFontLigatures.OFF, - // selectionsOnLine - // ); - tokens = lineData.tokens; tokenStartIndex = lineData.minColumn - 1; tokenEndIndex = 0; @@ -255,8 +302,8 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra glyph = this._viewGpuContext.atlas.getGlyph(this._glyphRasterizer, chars, tokenMetadata); // TODO: Support non-standard character widths - screenAbsoluteX = Math.round((x + xOffset) * viewLineOptions.spaceWidth * dpr); - screenAbsoluteY = ( + absoluteOffsetX = Math.round((x + xOffset) * viewLineOptions.spaceWidth * dpr); + absoluteOffsetY = ( Math.ceil(( // Top of line including line height viewportData.relativeVerticalOffset[y - viewportData.startLineNumber] + @@ -264,14 +311,10 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra Math.floor((viewportData.lineHeight - this._context.configuration.options.get(EditorOption.fontSize)) / 2) ) * dpr) ); - zeroToOneX = screenAbsoluteX / this._viewGpuContext.canvas.domNode.width; - zeroToOneY = screenAbsoluteY / this._viewGpuContext.canvas.domNode.height; - wgslX = zeroToOneX * 2 - 1; - wgslY = zeroToOneY * 2 - 1; cellIndex = ((y - 1) * this._viewGpuContext.maxGpuCols + x) * Constants.IndicesPerCell; - cellBuffer[cellIndex + CellBufferInfo.Offset_X] = wgslX; - cellBuffer[cellIndex + CellBufferInfo.Offset_Y] = -wgslY; + cellBuffer[cellIndex + CellBufferInfo.Offset_X] = absoluteOffsetX; + cellBuffer[cellIndex + CellBufferInfo.Offset_Y] = absoluteOffsetY; cellBuffer[cellIndex + CellBufferInfo.GlyphIndex] = glyph.glyphIndex; cellBuffer[cellIndex + CellBufferInfo.TextureIndex] = glyph.pageIndex; } @@ -325,8 +368,4 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra this._queuedBufferUpdates[0].push(e); this._queuedBufferUpdates[1].push(e); } - - onLinesDeleted(e: ViewLinesDeletedEvent): void { - this._queueBufferUpdate(e); - } } diff --git a/src/vs/editor/browser/gpu/fullFileRenderStrategy.wgsl.ts b/src/vs/editor/browser/gpu/fullFileRenderStrategy.wgsl.ts index c5072ffb616..531986de396 100644 --- a/src/vs/editor/browser/gpu/fullFileRenderStrategy.wgsl.ts +++ b/src/vs/editor/browser/gpu/fullFileRenderStrategy.wgsl.ts @@ -67,7 +67,12 @@ struct VSOutput { var vsOut: VSOutput; // Multiple vert.position by 2,-2 to get it into clipspace which ranged from -1 to 1 vsOut.position = vec4f( - (((vert.position * vec2f(2, -2)) / layoutInfo.canvasDims)) * glyph.size + cell.position + ((glyph.origin * vec2f(2, -2)) / layoutInfo.canvasDims) + (((layoutInfo.viewportOffset - scrollOffset.offset * vec2(1, -1)) * 2) / layoutInfo.canvasDims), + // Make everything relative to top left instead of center + vec2f(-1, 1) + + ((vert.position * vec2f(2, -2)) / layoutInfo.canvasDims) * glyph.size + + ((cell.position * vec2f(2, -2)) / layoutInfo.canvasDims) + + ((glyph.origin * vec2f(2, -2)) / layoutInfo.canvasDims) + + (((layoutInfo.viewportOffset - scrollOffset.offset * vec2(1, -1)) * 2) / layoutInfo.canvasDims), 0.0, 1.0 ); diff --git a/src/vs/editor/browser/gpu/gpu.ts b/src/vs/editor/browser/gpu/gpu.ts index 02506309202..8d965fcc5aa 100644 --- a/src/vs/editor/browser/gpu/gpu.ts +++ b/src/vs/editor/browser/gpu/gpu.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ViewLinesDeletedEvent } from '../../common/viewEvents.js'; +import type { ViewConfigurationChangedEvent, ViewLinesChangedEvent, ViewLinesDeletedEvent, ViewLinesInsertedEvent, ViewScrollChangedEvent, ViewTokensChangedEvent } from '../../common/viewEvents.js'; import type { ViewportData } from '../../common/viewLayout/viewLinesViewportData.js'; import type { ViewLineOptions } from '../viewParts/viewLines/viewLineOptions.js'; @@ -22,7 +22,12 @@ export interface IGpuRenderStrategy { readonly wgsl: string; readonly bindGroupEntries: GPUBindGroupEntry[]; - onLinesDeleted(e: ViewLinesDeletedEvent): void; + onLinesDeleted(e: ViewLinesDeletedEvent): boolean; + onConfigurationChanged(e: ViewConfigurationChangedEvent): boolean; + onTokensChanged(e: ViewTokensChangedEvent): boolean; + onLinesInserted(e: ViewLinesInsertedEvent): boolean; + onLinesChanged(e: ViewLinesChangedEvent): boolean; + onScrollChanged(e?: ViewScrollChangedEvent): boolean; /** * Resets the render strategy, clearing all data and setting up for a new frame. diff --git a/src/vs/editor/browser/gpu/rectangleRenderer.ts b/src/vs/editor/browser/gpu/rectangleRenderer.ts index 54ac68f9b98..d0e087dbbc8 100644 --- a/src/vs/editor/browser/gpu/rectangleRenderer.ts +++ b/src/vs/editor/browser/gpu/rectangleRenderer.ts @@ -8,7 +8,7 @@ import { Event } from '../../../base/common/event.js'; import { IReference, MutableDisposable } from '../../../base/common/lifecycle.js'; import { EditorOption } from '../../common/config/editorOptions.js'; import { ViewEventHandler } from '../../common/viewEventHandler.js'; -import type { ViewCursorStateChangedEvent, ViewScrollChangedEvent } from '../../common/viewEvents.js'; +import type { ViewScrollChangedEvent } from '../../common/viewEvents.js'; import type { ViewportData } from '../../common/viewLayout/viewLinesViewportData.js'; import type { ViewContext } from '../../common/viewModel/viewContext.js'; import { GPULifecycle } from './gpuDisposable.js'; @@ -244,10 +244,6 @@ export class RectangleRenderer extends ViewEventHandler { // #region Event handlers public override onScrollChanged(e: ViewScrollChangedEvent): boolean { - return true; - } - - public override onCursorStateChanged(e: ViewCursorStateChangedEvent): boolean { if (this._device) { const dpr = getActiveWindow().devicePixelRatio; this._scrollOffsetValueBuffer[0] = this._context.viewLayout.getCurrentScrollLeft() * dpr; diff --git a/src/vs/editor/browser/gpu/viewGpuContext.ts b/src/vs/editor/browser/gpu/viewGpuContext.ts index 3ce60a6c833..307636b37e1 100644 --- a/src/vs/editor/browser/gpu/viewGpuContext.ts +++ b/src/vs/editor/browser/gpu/viewGpuContext.ts @@ -138,4 +138,28 @@ export class ViewGpuContext extends Disposable { } return true; } + + /** + * Like {@link canRender} but returned detailed information about why the line cannot be rendered. + */ + public static canRenderDetailed(options: ViewLineOptions, viewportData: ViewportData, lineNumber: number): string[] { + const data = viewportData.getViewLineRenderingData(lineNumber); + const reasons: string[] = []; + if (data.containsRTL) { + reasons.push('containsRTL'); + } + if (data.maxColumn > GpuRenderLimits.maxGpuCols) { + reasons.push('maxColumn > maxGpuCols'); + } + if (data.continuesWithWrappedLine) { + reasons.push('continuesWithWrappedLine'); + } + if (data.inlineDecorations.length > 0) { + reasons.push('inlineDecorations > 0'); + } + if (lineNumber >= GpuRenderLimits.maxGpuLines) { + reasons.push('lineNumber >= maxGpuLines'); + } + return reasons; + } } diff --git a/src/vs/editor/browser/services/abstractCodeEditorService.ts b/src/vs/editor/browser/services/abstractCodeEditorService.ts index 3e927a9a437..0743bcae74a 100644 --- a/src/vs/editor/browser/services/abstractCodeEditorService.ts +++ b/src/vs/editor/browser/services/abstractCodeEditorService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../base/browser/dom.js'; +import * as domStylesheets from '../../../base/browser/domStylesheets.js'; import * as cssJs from '../../../base/browser/cssValue.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { IDisposable, DisposableStore, Disposable, toDisposable } from '../../../base/common/lifecycle.js'; @@ -128,7 +129,7 @@ export abstract class AbstractCodeEditorService extends Disposable implements IC } protected _createGlobalStyleSheet(): GlobalStyleSheet { - return new GlobalStyleSheet(dom.createStyleSheet()); + return new GlobalStyleSheet(domStylesheets.createStyleSheet()); } private _getOrCreateStyleSheet(editor: ICodeEditor | undefined): GlobalStyleSheet | RefCountedStyleSheet { @@ -141,7 +142,7 @@ export abstract class AbstractCodeEditorService extends Disposable implements IC } const editorId = editor.getId(); if (!this._editorStyleSheets.has(editorId)) { - const refCountedStyleSheet = new RefCountedStyleSheet(this, editorId, dom.createStyleSheet(domNode)); + const refCountedStyleSheet = new RefCountedStyleSheet(this, editorId, domStylesheets.createStyleSheet(domNode)); this._editorStyleSheets.set(editorId, refCountedStyleSheet); } return this._editorStyleSheets.get(editorId)!; @@ -348,11 +349,11 @@ class RefCountedStyleSheet { } public insertRule(selector: string, rule: string): void { - dom.createCSSRule(selector, rule, this._styleSheet); + domStylesheets.createCSSRule(selector, rule, this._styleSheet); } public removeRulesContainingSelector(ruleName: string): void { - dom.removeCSSRulesContainingSelector(ruleName, this._styleSheet); + domStylesheets.removeCSSRulesContainingSelector(ruleName, this._styleSheet); } } @@ -374,11 +375,11 @@ export class GlobalStyleSheet { } public insertRule(selector: string, rule: string): void { - dom.createCSSRule(selector, rule, this._styleSheet); + domStylesheets.createCSSRule(selector, rule, this._styleSheet); } public removeRulesContainingSelector(ruleName: string): void { - dom.removeCSSRulesContainingSelector(ruleName, this._styleSheet); + domStylesheets.removeCSSRulesContainingSelector(ruleName, this._styleSheet); } } diff --git a/src/vs/editor/browser/view.ts b/src/vs/editor/browser/view.ts index cef27e15410..9c0fa99f047 100644 --- a/src/vs/editor/browser/view.ts +++ b/src/vs/editor/browser/view.ts @@ -62,6 +62,7 @@ import { IVisibleRangeProvider, TextAreaEditContext } from './controller/editCon import { NativeEditContext } from './controller/editContext/native/nativeEditContext.js'; import { RulersGpu } from './viewParts/rulersGpu/rulersGpu.js'; import { EditContext } from './controller/editContext/native/editContextFactory.js'; +import { GpuMarkOverlay } from './viewParts/gpuMark/gpuMark.js'; export interface IContentWidgetData { @@ -194,6 +195,9 @@ export class View extends ViewEventHandler { marginViewOverlays.addDynamicOverlay(new MarginViewLineDecorationsOverlay(this._context)); marginViewOverlays.addDynamicOverlay(new LinesDecorationsOverlay(this._context)); marginViewOverlays.addDynamicOverlay(new LineNumbersOverlay(this._context)); + if (this._viewGpuContext) { + marginViewOverlays.addDynamicOverlay(new GpuMarkOverlay(this._context)); + } // Glyph margin widgets this._glyphMarginWidgets = new GlyphMarginWidgets(this._context); diff --git a/src/vs/editor/browser/view/renderingContext.ts b/src/vs/editor/browser/view/renderingContext.ts index f0d5b5357c1..e5ec4b30eca 100644 --- a/src/vs/editor/browser/view/renderingContext.ts +++ b/src/vs/editor/browser/view/renderingContext.ts @@ -85,17 +85,13 @@ export class RenderingContext extends RestrictedRenderingContext { return domRanges ?? null; } const gpuRanges = this._viewLinesGpu.linesVisibleRangesForRange(range, includeNewLines); - if (!domRanges && !gpuRanges) { - return null; + if (!domRanges) { + return gpuRanges; } - const ranges = []; - if (domRanges) { - ranges.push(...domRanges); + if (!gpuRanges) { + return domRanges; } - if (gpuRanges) { - ranges.push(...gpuRanges); - } - return ranges; + return domRanges.concat(gpuRanges).sort((a, b) => a.lineNumber - b.lineNumber); } public visibleRangeForPosition(position: Position): HorizontalPosition | null { diff --git a/src/vs/editor/browser/viewParts/gpuMark/gpuMark.css b/src/vs/editor/browser/viewParts/gpuMark/gpuMark.css new file mode 100644 index 00000000000..988c4bcb24a --- /dev/null +++ b/src/vs/editor/browser/viewParts/gpuMark/gpuMark.css @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-editor .margin-view-overlays .gpu-mark { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 100%; + display: inline-block; + border-left: solid 2px var(--vscode-editorWarning-foreground); + opacity: 0.2; + transition: background-color 0.1s linear; +} + +.monaco-editor .margin-view-overlays .gpu-mark:hover { + background-color: var(--vscode-editorWarning-foreground) +} diff --git a/src/vs/editor/browser/viewParts/gpuMark/gpuMark.ts b/src/vs/editor/browser/viewParts/gpuMark/gpuMark.ts new file mode 100644 index 00000000000..7019eff5e00 --- /dev/null +++ b/src/vs/editor/browser/viewParts/gpuMark/gpuMark.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as viewEvents from '../../../common/viewEvents.js'; +import { ViewContext } from '../../../common/viewModel/viewContext.js'; +import { ViewGpuContext } from '../../gpu/viewGpuContext.js'; +import { DynamicViewOverlay } from '../../view/dynamicViewOverlay.js'; +import { RenderingContext } from '../../view/renderingContext.js'; +import { ViewLineOptions } from '../viewLines/viewLineOptions.js'; +import './gpuMark.css'; + +/** + * A mark on lines to make identification of GPU-rendered lines vs DOM easier. + */ +export class GpuMarkOverlay extends DynamicViewOverlay { + + public static readonly CLASS_NAME = 'gpu-mark'; + + private readonly _context: ViewContext; + + private _renderResult: string[] | null; + + constructor(context: ViewContext) { + super(); + this._context = context; + this._renderResult = null; + this._context.addEventHandler(this); + } + + public override dispose(): void { + this._context.removeEventHandler(this); + this._renderResult = null; + super.dispose(); + } + + // --- begin event handlers + + public override onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean { + return true; + } + public override onCursorStateChanged(e: viewEvents.ViewCursorStateChangedEvent): boolean { + return true; + } + public override onFlushed(e: viewEvents.ViewFlushedEvent): boolean { + return true; + } + public override onLinesChanged(e: viewEvents.ViewLinesChangedEvent): boolean { + return true; + } + public override onLinesDeleted(e: viewEvents.ViewLinesDeletedEvent): boolean { + return true; + } + public override onLinesInserted(e: viewEvents.ViewLinesInsertedEvent): boolean { + return true; + } + public override onScrollChanged(e: viewEvents.ViewScrollChangedEvent): boolean { + return e.scrollTopChanged; + } + public override onZonesChanged(e: viewEvents.ViewZonesChangedEvent): boolean { + return true; + } + public override onDecorationsChanged(e: viewEvents.ViewDecorationsChangedEvent): boolean { + return true; + } + + // --- end event handlers + + public prepareRender(ctx: RenderingContext): void { + const visibleStartLineNumber = ctx.visibleRange.startLineNumber; + const visibleEndLineNumber = ctx.visibleRange.endLineNumber; + + const viewportData = ctx.viewportData; + const options = new ViewLineOptions(this._context.configuration, this._context.theme.type); + + const output: string[] = []; + for (let lineNumber = visibleStartLineNumber; lineNumber <= visibleEndLineNumber; lineNumber++) { + const lineIndex = lineNumber - visibleStartLineNumber; + const cannotRenderReasons = ViewGpuContext.canRenderDetailed(options, viewportData, lineNumber); + output[lineIndex] = cannotRenderReasons.length ? `
` : ''; + } + + this._renderResult = output; + } + + public render(startLineNumber: number, lineNumber: number): string { + if (!this._renderResult) { + return ''; + } + const lineIndex = lineNumber - startLineNumber; + if (lineIndex < 0 || lineIndex >= this._renderResult.length) { + return ''; + } + return this._renderResult[lineIndex]; + } +} diff --git a/src/vs/editor/browser/viewParts/rulersGpu/rulersGpu.ts b/src/vs/editor/browser/viewParts/rulersGpu/rulersGpu.ts index 8db2dc2823e..af0b20eb9a7 100644 --- a/src/vs/editor/browser/viewParts/rulersGpu/rulersGpu.ts +++ b/src/vs/editor/browser/viewParts/rulersGpu/rulersGpu.ts @@ -10,7 +10,7 @@ import * as viewEvents from '../../../common/viewEvents.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; import type { ViewGpuContext } from '../../gpu/viewGpuContext.js'; import type { IObjectCollectionBufferEntry } from '../../gpu/objectCollectionBuffer.js'; -import type { RectangleRendererEntrySpec } from '../../gpu/rectangleRenderer.js'; +import type { RectangleRenderer, RectangleRendererEntrySpec } from '../../gpu/rectangleRenderer.js'; import { Color } from '../../../../base/common/color.js'; import { editorRuler } from '../../../common/core/editorColorRegistry.js'; import { autorun, type IReader } from '../../../../base/common/observable.js'; @@ -57,7 +57,7 @@ export class RulersGpu extends ViewPart { const ruler = rulers[i]; const shape = this._gpuShapes[i]; const color = ruler.color ? Color.fromHex(ruler.color) : this._context.theme.getColor(editorRuler) ?? Color.white; - const rulerData = [ + const rulerData: Parameters = [ ruler.column * typicalHalfwidthCharacterWidth * devicePixelRatio, 0, Math.max(1, Math.ceil(devicePixelRatio)), @@ -66,7 +66,7 @@ export class RulersGpu extends ViewPart { color.rgba.g / 255, color.rgba.b / 255, color.rgba.a, - ] as const; + ]; if (!shape) { this._gpuShapes[i] = this._viewGpuContext.rectangleRenderer.register(...rulerData); } else { diff --git a/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts b/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts index 13638988242..c4d8c0400d2 100644 --- a/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts +++ b/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts @@ -5,25 +5,24 @@ import { getActiveWindow } from '../../../../base/browser/dom.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { autorun, observableValue, runOnChange } from '../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; import { Position } from '../../../common/core/position.js'; import { Range } from '../../../common/core/range.js'; -import type { ViewConfigurationChangedEvent, ViewCursorStateChangedEvent, ViewLinesChangedEvent, ViewLinesDeletedEvent, ViewScrollChangedEvent } from '../../../common/viewEvents.js'; import type { ViewportData } from '../../../common/viewLayout/viewLinesViewportData.js'; import type { ViewContext } from '../../../common/viewModel/viewContext.js'; import { TextureAtlasPage } from '../../gpu/atlas/textureAtlasPage.js'; import { FullFileRenderStrategy } from '../../gpu/fullFileRenderStrategy.js'; import { BindingId, type IGpuRenderStrategy } from '../../gpu/gpu.js'; import { GPULifecycle } from '../../gpu/gpuDisposable.js'; -import { observeDevicePixelDimensions, quadVertices } from '../../gpu/gpuUtils.js'; +import { quadVertices } from '../../gpu/gpuUtils.js'; import { ViewGpuContext } from '../../gpu/viewGpuContext.js'; import { FloatHorizontalRange, HorizontalPosition, HorizontalRange, IViewLines, LineVisibleRanges, RenderingContext, RestrictedRenderingContext, VisibleRanges } from '../../view/renderingContext.js'; import { ViewPart } from '../../view/viewPart.js'; import { ViewLineOptions } from '../viewLines/viewLineOptions.js'; - +import type * as viewEvents from '../../../common/viewEvents.js'; const enum GlyphStorageBufferInfo { FloatsPerEntry = 2 + 2 + 2, @@ -60,6 +59,8 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { private _renderStrategy!: IGpuRenderStrategy; + private _contentLeftObs = observableValue('contentLeft', 0); + constructor( context: ViewContext, private readonly _viewGpuContext: ViewGpuContext, @@ -154,8 +155,11 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { size: Info.BytesPerEntry, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }, () => updateBufferValues())).object; - this._register(observeDevicePixelDimensions(this.canvas, getActiveWindow(), (w, h) => { - this._device.queue.writeBuffer(layoutInfoUniformBuffer, 0, updateBufferValues(w, h)); + this._register(runOnChange(this._viewGpuContext.canvasDevicePixelDimensions, ({ width, height }) => { + this._device.queue.writeBuffer(layoutInfoUniformBuffer, 0, updateBufferValues(width, height)); + })); + this._register(runOnChange(this._contentLeftObs, () => { + this._device.queue.writeBuffer(layoutInfoUniformBuffer, 0, updateBufferValues()); })); } @@ -351,8 +355,8 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { } }, { - width: page.usedArea.right - page.usedArea.left, - height: page.usedArea.bottom - page.usedArea.top + width: page.usedArea.right - page.usedArea.left + 1, + height: page.usedArea.bottom - page.usedArea.top + 1 }, ); } @@ -373,25 +377,24 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { // Since ViewLinesGpu currently coordinates rendering to the canvas, it must listen to all // changed events that any GPU part listens to. This is because any drawing to the canvas will // clear it for that frame, so all parts must be rendered every time. + // + // Additionally, since this is intrinsically linked to ViewLines, it must also listen to events + // from that side. Luckily rendering is cheap, it's only when uploaded data changes does it + // start to cost. - override onConfigurationChanged(e: ViewConfigurationChangedEvent): boolean { - return true; - } + override onCursorStateChanged(e: viewEvents.ViewCursorStateChangedEvent): boolean { return true; } + override onDecorationsChanged(e: viewEvents.ViewDecorationsChangedEvent): boolean { return true; } + override onFlushed(e: viewEvents.ViewFlushedEvent): boolean { return true; } + override onLinesChanged(e: viewEvents.ViewLinesChangedEvent): boolean { return true; } + override onLinesDeleted(e: viewEvents.ViewLinesDeletedEvent): boolean { return true; } + override onLinesInserted(e: viewEvents.ViewLinesInsertedEvent): boolean { return true; } + override onRevealRangeRequest(e: viewEvents.ViewRevealRangeRequestEvent): boolean { return true; } + override onScrollChanged(e: viewEvents.ViewScrollChangedEvent): boolean { return true; } + override onThemeChanged(e: viewEvents.ViewThemeChangedEvent): boolean { return true; } + override onZonesChanged(e: viewEvents.ViewZonesChangedEvent): boolean { return true; } - override onLinesChanged(e: ViewLinesChangedEvent): boolean { - return true; - } - - override onLinesDeleted(e: ViewLinesDeletedEvent): boolean { - this._renderStrategy.onLinesDeleted(e); - return true; - } - - override onScrollChanged(e: ViewScrollChangedEvent): boolean { - return true; - } - - override onCursorStateChanged(e: ViewCursorStateChangedEvent): boolean { + override onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean { + this._contentLeftObs.set(this._context.configuration.options.get(EditorOption.layoutInfo).contentLeft, undefined); return true; } diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 107a4fde98f..9e50bfd86ed 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -1655,6 +1655,10 @@ export interface IEditorFindOptions { * Controls whether the search result and diff result automatically restarts from the beginning (or the end) when no further matches can be found */ loop?: boolean; + /** + * Controls how the find widget search history should be stored + */ + findSearchHistory?: 'never' | 'workspace'; } /** @@ -1671,7 +1675,8 @@ class EditorFind extends BaseEditorOption(input.findSearchHistory, this.defaultValue.findSearchHistory, ['never', 'workspace']), }; } } diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index a3156dea8ab..013fb74b704 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -760,6 +760,11 @@ export interface InlineCompletion { readonly command?: Command; + /** + * Is called the first time an inline completion is shown. + */ + readonly shownCommand?: Command; + /** * If set to `true`, unopened closing brackets are removed and unclosed opening brackets are closed. * Defaults to `false`. @@ -2345,6 +2350,7 @@ export interface IInlineEdit { range: IRange; accepted?: Command; rejected?: Command; + shown?: Command; commands?: Command[]; } diff --git a/src/vs/editor/common/services/getIconClasses.ts b/src/vs/editor/common/services/getIconClasses.ts index 61fd63dc827..cb60d8b7bf5 100644 --- a/src/vs/editor/common/services/getIconClasses.ts +++ b/src/vs/editor/common/services/getIconClasses.ts @@ -35,13 +35,13 @@ export function getIconClasses(modelService: IModelService, languageService: ILa } else { const match = resource.path.match(fileIconDirectoryRegex); if (match) { - name = cssEscape(match[2].toLowerCase()); + name = fileIconSelectorEscape(match[2].toLowerCase()); if (match[1]) { - classes.push(`${cssEscape(match[1].toLowerCase())}-name-dir-icon`); // parent directory + classes.push(`${fileIconSelectorEscape(match[1].toLowerCase())}-name-dir-icon`); // parent directory } } else { - name = cssEscape(resource.authority.toLowerCase()); + name = fileIconSelectorEscape(resource.authority.toLowerCase()); } } @@ -77,7 +77,7 @@ export function getIconClasses(modelService: IModelService, languageService: ILa // Detected Mode const detectedLanguageId = detectLanguageId(modelService, languageService, resource); if (detectedLanguageId) { - classes.push(`${cssEscape(detectedLanguageId)}-lang-file-icon`); + classes.push(`${fileIconSelectorEscape(detectedLanguageId)}-lang-file-icon`); } } } @@ -85,7 +85,7 @@ export function getIconClasses(modelService: IModelService, languageService: ILa } export function getIconClassesForLanguageId(languageId: string): string[] { - return ['file-icon', `${cssEscape(languageId)}-lang-file-icon`]; + return ['file-icon', `${fileIconSelectorEscape(languageId)}-lang-file-icon`]; } function detectLanguageId(modelService: IModelService, languageService: ILanguageService, resource: uri): string | null { @@ -122,6 +122,6 @@ function detectLanguageId(modelService: IModelService, languageService: ILanguag return languageService.guessLanguageIdByFilepathOrFirstLine(resource); } -function cssEscape(str: string): string { +export function fileIconSelectorEscape(str: string): string { return str.replace(/[\s]/g, '/'); // HTML class names can not contain certain whitespace characters (https://dom.spec.whatwg.org/#interface-domtokenlist), use / instead, which doesn't exist in file names. } diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index bb3054e0db1..b3a6a04e3a1 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -46,6 +46,8 @@ import { Selection } from '../../../common/core/selection.js'; import { createInstantHoverDelegate, getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { FindWidgetSearchHistory } from './findWidgetSearchHistory.js'; +import { IHistory } from '../../../../base/common/history.js'; const findCollapsedIcon = registerIcon('find-collapsed', Codicon.chevronRight, nls.localize('findCollapsedIcon', 'Icon to indicate that the editor find widget is collapsed.')); const findExpandedIcon = registerIcon('find-expanded', Codicon.chevronDown, nls.localize('findExpandedIcon', 'Icon to indicate that the editor find widget is expanded.')); @@ -131,6 +133,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private readonly _contextKeyService: IContextKeyService; private readonly _storageService: IStorageService; private readonly _notificationService: INotificationService; + private _findWidgetSearchHistory: IHistory; private _domNode!: HTMLElement; private _cachedHeight: number | null = null; @@ -183,6 +186,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._contextKeyService = contextKeyService; this._storageService = storageService; this._notificationService = notificationService; + this._findWidgetSearchHistory = new FindWidgetSearchHistory(this._storageService); this._ctrlEnterReplaceAllWarningPrompted = !!storageService.getBoolean(ctrlEnterReplaceAllWarningPromptedKey, StorageScope.PROFILE); @@ -939,6 +943,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL const flexibleHeight = true; const flexibleWidth = true; // Find input + const findSearchHistoryConfig = this._codeEditor.getOption(EditorOption.find).findSearchHistory; this._findInput = this._register(new ContextScopedFindInput(null, this._contextViewProvider, { width: FIND_INPUT_AREA_WIDTH, label: NLS_FIND_INPUT_LABEL, @@ -964,7 +969,8 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL showCommonFindToggles: true, showHistoryHint: () => showHistoryKeybindingHint(this._keybindingService), inputBoxStyles: defaultInputBoxStyles, - toggleStyles: defaultToggleStyles + toggleStyles: defaultToggleStyles, + history: findSearchHistoryConfig === 'workspace' ? this._findWidgetSearchHistory : new Set([]), }, this._contextKeyService)); this._findInput.setRegex(!!this._state.isRegex); this._findInput.setCaseSensitive(!!this._state.matchCase); diff --git a/src/vs/editor/contrib/find/browser/findWidgetSearchHistory.ts b/src/vs/editor/contrib/find/browser/findWidgetSearchHistory.ts new file mode 100644 index 00000000000..0dd6f0753a1 --- /dev/null +++ b/src/vs/editor/contrib/find/browser/findWidgetSearchHistory.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IHistory } from '../../../../base/common/history.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +export class FindWidgetSearchHistory implements IHistory { + public static readonly FIND_HISTORY_KEY = 'workbench.find.history'; + private inMemoryValues: Set = new Set(); + + constructor( + @IStorageService private readonly storageService: IStorageService, + ) { + this.load(); + } + + delete(t: string): boolean { + const result = this.inMemoryValues.delete(t); + this.save(); + return result; + } + + add(t: string): this { + this.inMemoryValues.add(t); + this.save(); + return this; + } + + has(t: string): boolean { + return this.inMemoryValues.has(t); + } + + clear(): void { + this.inMemoryValues.clear(); + this.save(); + } + + forEach(callbackfn: (value: string, value2: string, set: Set) => void, thisArg?: any): void { + // fetch latest from storage + this.load(); + return this.inMemoryValues.forEach(callbackfn); + } + replace?(t: string[]): void { + this.inMemoryValues = new Set(t); + this.save(); + } + + load() { + let result: [] | undefined; + const raw = this.storageService.get( + FindWidgetSearchHistory.FIND_HISTORY_KEY, + StorageScope.WORKSPACE + ); + + if (raw) { + try { + result = JSON.parse(raw); + } catch (e) { + // Invalid data + } + } + + this.inMemoryValues = new Set(result || []); + } + + // Run saves async + save(): Promise { + const elements: string[] = []; + this.inMemoryValues.forEach(e => elements.push(e)); + return new Promise(resolve => { + this.storageService.store( + FindWidgetSearchHistory.FIND_HISTORY_KEY, + JSON.stringify(elements), + StorageScope.WORKSPACE, + StorageTarget.USER, + ); + resolve(); + }); + } +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts index d369260f852..9447ee4cb86 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts @@ -32,7 +32,7 @@ import { ILanguageFeaturesService } from '../../../../common/services/languageFe import { InlineCompletionsHintsWidget, InlineSuggestionHintsContentWidget } from '../hintsWidget/inlineCompletionsHintsWidget.js'; import { TextModelChangeRecorder } from '../model/changeRecorder.js'; import { InlineCompletionsModel } from '../model/inlineCompletionsModel.js'; -import { SuggestWidgetAdaptor } from '../model/suggestWidgetAdaptor.js'; +import { SuggestWidgetAdaptor } from '../model/suggestWidgetAdapter.js'; import { convertItemsToStableObservables, ObservableContextKeyService } from '../utils.js'; import { GhostTextView } from '../view/ghostText/ghostTextView.js'; import { InlineEditsViewAndDiffProducer } from '../view/inlineEdits/inlineEditsViewAndDiffProducer.js'; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts index 9fbeb4fbd12..10670fc68b1 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts @@ -35,7 +35,7 @@ import { InlineCompletionWithUpdatedRange, InlineCompletionsSource } from './inl import { InlineEdit } from './inlineEdit.js'; import { InlineCompletionItem } from './provideInlineCompletions.js'; import { singleTextEditAugments, singleTextRemoveCommonPrefix } from './singleTextEditHelpers.js'; -import { SuggestItemInfo } from './suggestWidgetAdaptor.js'; +import { SuggestItemInfo } from './suggestWidgetAdapter.js'; export class InlineCompletionsModel extends Disposable { private readonly _source = this._register(this._instantiationService.createInstance(InlineCompletionsSource, this.textModel, this._textModelVersionId, this._debounceValue)); @@ -208,26 +208,32 @@ export class InlineCompletionsModel extends Disposable { const c = this._source.inlineCompletions.read(reader); if (!c) { return undefined; } const cursorPosition = this._primaryPosition.read(reader); - let inlineEditCompletion: InlineCompletionWithUpdatedRange | undefined = undefined; - const filteredCompletions: InlineCompletionWithUpdatedRange[] = []; + let inlineEdit: InlineCompletionWithUpdatedRange | undefined = undefined; + const visibleCompletions: InlineCompletionWithUpdatedRange[] = []; for (const completion of c.inlineCompletions) { if (!completion.inlineCompletion.sourceInlineCompletion.isInlineEdit) { if (completion.isVisible(this.textModel, cursorPosition, reader)) { - filteredCompletions.push(completion); + visibleCompletions.push(completion); } - } else if (filteredCompletions.length === 0 && completion.inlineCompletion.sourceInlineCompletion.isInlineEdit) { - inlineEditCompletion = completion; + } else { + inlineEdit = completion; } } + + if (visibleCompletions.length !== 0) { + // Don't show the inline edit if there is a visible completion + inlineEdit = undefined; + } + return { - items: filteredCompletions, - inlineEditCompletion, + inlineCompletions: visibleCompletions, + inlineEdit, }; }); private readonly _filteredInlineCompletionItems = derivedOpts({ owner: this, equalsFn: itemsEquals() }, reader => { const c = this._inlineCompletionItems.read(reader); - return c?.items ?? []; + return c?.inlineCompletions ?? []; }); public readonly selectedInlineCompletionIndex = derived(this, (reader) => { @@ -295,8 +301,8 @@ export class InlineCompletionsModel extends Disposable { const model = this.textModel; const item = this._inlineCompletionItems.read(reader); - if (item?.inlineEditCompletion) { - let edit = item.inlineEditCompletion.toSingleTextEdit(reader); + if (item?.inlineEdit) { + let edit = item.inlineEdit.toSingleTextEdit(reader); edit = singleTextRemoveCommonPrefix(edit, model); const cursorPos = this._primaryPosition.read(reader); @@ -304,13 +310,13 @@ export class InlineCompletionsModel extends Disposable { const cursorDist = LineRange.fromRange(edit.range).distanceToLine(this._primaryPosition.read(reader).lineNumber); const disableCollapsing = true; - const currentItemIsCollapsed = !disableCollapsing && (cursorDist > 1 && this._collapsedInlineEditId.read(reader) === item.inlineEditCompletion.semanticId); + const currentItemIsCollapsed = !disableCollapsing && (cursorDist > 1 && this._collapsedInlineEditId.read(reader) === item.inlineEdit.semanticId); - const commands = item.inlineEditCompletion.inlineCompletion.source.inlineCompletions.commands; + const commands = item.inlineEdit.inlineCompletion.source.inlineCompletions.commands; const renderExplicitly = this._jumpedTo.read(reader); - const inlineEdit = new InlineEdit(edit, currentItemIsCollapsed, renderExplicitly, commands ?? []); + const inlineEdit = new InlineEdit(edit, currentItemIsCollapsed, renderExplicitly, commands ?? [], item.inlineEdit.inlineCompletion); - return { kind: 'inlineEdit', inlineEdit, inlineCompletion: item.inlineEditCompletion, edits: [edit], cursorAtInlineEdit }; + return { kind: 'inlineEdit', inlineEdit, inlineCompletion: item.inlineEdit, edits: [edit], cursorAtInlineEdit }; } this._jumpedTo.set(false, undefined); @@ -675,6 +681,17 @@ export class InlineCompletionsModel extends Disposable { this._editor.focus(); }); } + + public async handleInlineCompletionShown(inlineCompletion: InlineCompletionItem): Promise { + if (!inlineCompletion.shownCommand) { + return; + } + if (inlineCompletion.didShow) { + return; + } + inlineCompletion.markAsShown(); + await this._commandService.executeCommand(inlineCompletion.shownCommand.id, ...(inlineCompletion.shownCommand.arguments || [])); + } } interface Repro { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEdit.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEdit.ts index 6cdc111c1f1..d87d1c26d9b 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEdit.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEdit.ts @@ -5,6 +5,7 @@ import { SingleTextEdit } from '../../../../common/core/textEdit.js'; import { Command } from '../../../../common/languages.js'; +import { InlineCompletionItem } from './provideInlineCompletions.js'; export class InlineEdit { constructor( @@ -12,6 +13,7 @@ export class InlineEdit { public readonly isCollapsed: boolean, public readonly renderExplicitly: boolean, public readonly commands: readonly Command[], + public readonly inlineCompletion: InlineCompletionItem, ) { } public get range() { @@ -23,6 +25,9 @@ export class InlineEdit { } public equals(other: InlineEdit): boolean { - return this.edit.equals(other.edit) && this.isCollapsed === other.isCollapsed && this.renderExplicitly === other.renderExplicitly; + return this.edit.equals(other.edit) + && this.isCollapsed === other.isCollapsed + && this.renderExplicitly === other.renderExplicitly + && this.inlineCompletion === other.inlineCompletion; } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEditsAdapter.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEditsAdapter.ts index 4153ebb91f9..8d651a32a85 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEditsAdapter.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineEditsAdapter.ts @@ -71,6 +71,7 @@ export class InlineEditsAdapter extends Disposable { range: e.result.range, insertText: e.result.text, command: e.result.accepted, + shownCommand: e.result.shown, isInlineEdit: true, edit: e.result, }; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts index 9063a9f4b3c..91c047eff9d 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts @@ -193,7 +193,8 @@ async function addRefAndCreateResult( itemsByHash.set(inlineCompletionItem.hash(), inlineCompletionItem); - if (context.triggerKind === InlineCompletionTriggerKind.Automatic) { + // Stop after first visible inline completion + if (!item.isInlineEdit && context.triggerKind === InlineCompletionTriggerKind.Automatic) { const minifiedEdit = inlineCompletionItem.toSingleTextEdit().removeCommonPrefix(new TextModelText(model)); if (!minifiedEdit.isEmpty) { shouldStop = true; @@ -322,6 +323,7 @@ export class InlineCompletionItem { return new InlineCompletionItem( insertText, inlineCompletion.command, + inlineCompletion.shownCommand, range, insertText, snippetInfo, @@ -331,9 +333,12 @@ export class InlineCompletionItem { ); } + private _didCallShow = false; + constructor( readonly filterText: string, readonly command: Command | undefined, + readonly shownCommand: Command | undefined, readonly range: Range, readonly insertText: string, readonly snippetInfo: SnippetInfo | undefined, @@ -357,10 +362,18 @@ export class InlineCompletionItem { insertText = filterText.replace(/\r\n|\r/g, '\n'); } + public get didShow(): boolean { + return this._didCallShow; + } + public markAsShown(): void { + this._didCallShow = true; + } + public withRange(updatedRange: Range): InlineCompletionItem { return new InlineCompletionItem( this.filterText, this.command, + this.shownCommand, updatedRange, this.insertText, this.snippetInfo, diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdaptor.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts similarity index 100% rename from src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdaptor.ts rename to src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts index d8e6fdf62c3..65987b8e7b3 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts @@ -190,6 +190,8 @@ export class InlineEditsView extends Disposable { const edit = this._edit.read(reader); if (!edit) { return undefined; } + this._model.get()?.handleInlineCompletionShown(edit.inlineCompletion); + let mappings = RangeMapping.fromEdit(edit.edit); let newText = edit.edit.apply(edit.originalText); let diff = lineRangeMappingFromRangeMappings(mappings, edit.originalText, new StringText(newText)); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewAndDiffProducer.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewAndDiffProducer.ts index 6d6c9aaa7e1..ea3c17e867b 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewAndDiffProducer.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewAndDiffProducer.ts @@ -22,6 +22,7 @@ import { TextModelText } from '../../../../../common/model/textModelText.js'; import { IModelService } from '../../../../../common/services/model.js'; import { InlineCompletionsModel } from '../../model/inlineCompletionsModel.js'; import { InlineEdit } from '../../model/inlineEdit.js'; +import { InlineCompletionItem } from '../../model/provideInlineCompletions.js'; import { InlineEditsView } from './inlineEditsView.js'; import { UniqueUriGenerator } from './utils.js'; @@ -81,7 +82,7 @@ export class InlineEditsViewAndDiffProducer extends Disposable { )); const diffEdits = new TextEdit(edits); - return new InlineEditWithChanges(text, diffEdits, inlineEdit.isCollapsed, inlineEdit.renderExplicitly, inlineEdit.commands); //inlineEdit.showInlineIfPossible); + return new InlineEditWithChanges(text, diffEdits, inlineEdit.isCollapsed, inlineEdit.renderExplicitly, inlineEdit.commands, inlineEdit.inlineCompletion); //inlineEdit.showInlineIfPossible); }); }); @@ -112,7 +113,8 @@ export class InlineEditWithChanges { public readonly edit: TextEdit, public readonly isCollapsed: boolean, public readonly userJumpedToIt: boolean, - public readonly commands: readonly Command[] + public readonly commands: readonly Command[], + public readonly inlineCompletion: InlineCompletionItem, ) { } @@ -121,6 +123,7 @@ export class InlineEditWithChanges { this.edit.equals(other.edit) && this.isCollapsed === other.isCollapsed && this.userJumpedToIt === other.userJumpedToIt && - this.commands === other.commands; + this.commands === other.commands && + this.inlineCompletion === other.inlineCompletion; } } diff --git a/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts b/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts index 016f0638b6b..6d3c3b94893 100644 --- a/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts +++ b/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../base/browser/dom.js'; +import * as domStylesheetsJs from '../../../../base/browser/domStylesheets.js'; import { IHorizontalSashLayoutProvider, ISashEvent, Orientation, Sash, SashState } from '../../../../base/browser/ui/sash/sash.js'; import { Color, RGBA } from '../../../../base/common/color.js'; import { IdGenerator } from '../../../../base/common/idGenerator.js'; @@ -127,7 +128,7 @@ class Arrow { dispose(): void { this.hide(); - dom.removeCSSRulesContainingSelector(this._ruleName); + domStylesheetsJs.removeCSSRulesContainingSelector(this._ruleName); } set color(value: string) { @@ -145,8 +146,8 @@ class Arrow { } private _updateStyle(): void { - dom.removeCSSRulesContainingSelector(this._ruleName); - dom.createCSSRule( + domStylesheetsJs.removeCSSRulesContainingSelector(this._ruleName); + domStylesheetsJs.createCSSRule( `.monaco-editor ${this._ruleName}`, `border-style: solid; border-color: transparent; border-bottom-color: ${this._color}; border-width: ${this._height}px; bottom: -${this._height}px !important; margin-left: -${this._height}px; ` ); diff --git a/src/vs/editor/standalone/browser/standaloneThemeService.ts b/src/vs/editor/standalone/browser/standaloneThemeService.ts index eb2470683cc..8a697f653ff 100644 --- a/src/vs/editor/standalone/browser/standaloneThemeService.ts +++ b/src/vs/editor/standalone/browser/standaloneThemeService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../base/browser/dom.js'; +import * as domStylesheetsJs from '../../../base/browser/domStylesheets.js'; import { addMatchMediaChangeListener } from '../../../base/browser/browser.js'; import { Color } from '../../../base/common/color.js'; import { Emitter } from '../../../base/common/event.js'; @@ -275,7 +276,7 @@ export class StandaloneThemeService extends Disposable implements IStandaloneThe private _registerRegularEditorContainer(): IDisposable { if (!this._globalStyleElement) { - this._globalStyleElement = dom.createStyleSheet(undefined, style => { + this._globalStyleElement = domStylesheetsJs.createStyleSheet(undefined, style => { style.className = 'monaco-colors'; style.textContent = this._allCSS; }); @@ -285,7 +286,7 @@ export class StandaloneThemeService extends Disposable implements IStandaloneThe } private _registerShadowDomContainer(domNode: HTMLElement): IDisposable { - const styleElement = dom.createStyleSheet(domNode, style => { + const styleElement = domStylesheetsJs.createStyleSheet(domNode, style => { style.className = 'monaco-colors'; style.textContent = this._allCSS; }); diff --git a/src/vs/editor/test/browser/services/decorationRenderOptions.test.ts b/src/vs/editor/test/browser/services/decorationRenderOptions.test.ts index 7a35640c79b..74555b9a823 100644 --- a/src/vs/editor/test/browser/services/decorationRenderOptions.test.ts +++ b/src/vs/editor/test/browser/services/decorationRenderOptions.test.ts @@ -130,7 +130,7 @@ suite('Decoration Render Options', () => { // single quote must always be escaped/encoded s.registerDecorationType('test', 'example', { gutterIconPath: URI.file('c:\\files\\foo\\b\'ar.png') }); - assertBackground('file:///c:/files/foo/b%27ar.png', 'vscode-file://vscode-app/c:/files/foo/b%27ar.png'); + assertBackground('file:///c:/files/foo/b\\000027ar.png', 'vscode-file://vscode-app/c:/files/foo/b\\000027ar.png'); s.removeDecorationType('example'); } else { // unix file path (used as string) @@ -140,12 +140,12 @@ suite('Decoration Render Options', () => { // single quote must always be escaped/encoded s.registerDecorationType('test', 'example', { gutterIconPath: URI.file('/Users/foo/b\'ar.png') }); - assertBackground('file:///Users/foo/b%27ar.png', 'vscode-file://vscode-app/Users/foo/b%27ar.png'); + assertBackground('file:///Users/foo/b\\000027ar.png', 'vscode-file://vscode-app/Users/foo/b\\000027ar.png'); s.removeDecorationType('example'); } s.registerDecorationType('test', 'example', { gutterIconPath: URI.parse('http://test/pa\'th') }); - assert(readStyleSheet(styleSheet).indexOf(`{background:url('http://test/pa%27th') center center no-repeat;}`) > 0); + assert(readStyleSheet(styleSheet).indexOf(`{background:url('http://test/pa\\000027th') center center no-repeat;}`) > 0); s.removeDecorationType('example'); }); }); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 5e8409195fa..11b8f7415dc 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4061,6 +4061,10 @@ declare namespace monaco.editor { * Controls whether the search result and diff result automatically restarts from the beginning (or the end) when no further matches can be found */ loop?: boolean; + /** + * Controls how the find widget search history should be stored + */ + findSearchHistory?: 'never' | 'workspace'; } export type GoToLocationValues = 'peek' | 'gotoAndPeek' | 'goto'; @@ -7246,6 +7250,10 @@ declare namespace monaco.languages { */ readonly range?: IRange; readonly command?: Command; + /** + * Is called the first time an inline completion is shown. + */ + readonly shownCommand?: Command; /** * If set to `true`, unopened closing brackets are removed and unclosed opening brackets are closed. * Defaults to `false`. @@ -8134,6 +8142,7 @@ declare namespace monaco.languages { range: IRange; accepted?: Command; rejected?: Command; + shown?: Command; commands?: Command[]; } diff --git a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts index c7644ea23e2..3c8b7d5b719 100644 --- a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts +++ b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts @@ -566,7 +566,7 @@ export class AccessibilitySignal { public static readonly codeActionTriggered = AccessibilitySignal.register({ name: localize('accessibilitySignals.codeActionRequestTriggered', 'Code Action Request Triggered'), - sound: Sound.requestSent, + sound: Sound.voiceRecordingStarted, legacySoundSettingsKey: 'audioCues.codeActionRequestTriggered', legacyAnnouncementSettingsKey: 'accessibility.alert.codeActionRequestTriggered', announcementMessage: localize('accessibility.signals.codeActionRequestTriggered', 'Code Action Request Triggered'), @@ -576,14 +576,7 @@ export class AccessibilitySignal { public static readonly codeActionApplied = AccessibilitySignal.register({ name: localize('accessibilitySignals.codeActionApplied', 'Code Action Applied'), legacySoundSettingsKey: 'audioCues.codeActionApplied', - sound: { - randomOneOf: [ - Sound.responseReceived1, - Sound.responseReceived2, - Sound.responseReceived3, - Sound.responseReceived4 - ] - }, + sound: Sound.voiceRecordingStopped, settingsKey: 'accessibility.signals.codeActionApplied' }); diff --git a/src/vs/platform/configuration/common/configurationModels.ts b/src/vs/platform/configuration/common/configurationModels.ts index 5b7a51dced2..400ae1033cb 100644 --- a/src/vs/platform/configuration/common/configurationModels.ts +++ b/src/vs/platform/configuration/common/configurationModels.ts @@ -296,6 +296,7 @@ export class ConfigurationModel implements IConfigurationModel { } export interface ConfigurationParseOptions { + skipUnregistered?: boolean; scopes?: ConfigurationScope[]; skipRestricted?: boolean; include?: string[]; @@ -428,14 +429,10 @@ export class ConfigurationModelParser { restricted.push(...result.restricted); } else { const propertySchema = configurationProperties[key]; - const scope = propertySchema ? typeof propertySchema.scope !== 'undefined' ? propertySchema.scope : ConfigurationScope.WINDOW : undefined; if (propertySchema?.restricted) { restricted.push(key); } - if (!options.exclude?.includes(key) /* Check exclude */ - && (options.include?.includes(key) /* Check include */ - || ((scope === undefined || options.scopes === undefined || options.scopes.includes(scope)) /* Check scopes */ - && !(options.skipRestricted && propertySchema?.restricted)))) /* Check restricted */ { + if (this.shouldInclude(key, propertySchema, options)) { raw[key] = properties[key]; } else { hasExcludedProperties = true; @@ -445,6 +442,31 @@ export class ConfigurationModelParser { return { raw, restricted, hasExcludedProperties }; } + private shouldInclude(key: string, propertySchema: IConfigurationPropertySchema | undefined, options: ConfigurationParseOptions): boolean { + if (options.exclude?.includes(key)) { + return false; + } + + if (options.include?.includes(key)) { + return true; + } + + if (options.skipRestricted && propertySchema?.restricted) { + return false; + } + + if (options.skipUnregistered && !propertySchema) { + return false; + } + + const scope = propertySchema ? typeof propertySchema.scope !== 'undefined' ? propertySchema.scope : ConfigurationScope.WINDOW : undefined; + if (scope === undefined || options.scopes === undefined) { + return true; + } + + return options.scopes.includes(scope); + } + private toOverrides(raw: any, conflictReporter: (message: string) => void): IOverrides[] { const overrides: IOverrides[] = []; for (const key of Object.keys(raw)) { diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index ae3d50b2570..eaa80d0ef7b 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -3,8 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { equals } from '../../../base/common/arrays.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; +import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; export interface IResolvableEditorModel extends IDisposable { @@ -376,3 +378,29 @@ export interface ITextEditorOptions extends IEditorOptions { */ selectionSource?: TextEditorSelectionSource | string; } + +export type ITextEditorDiff = [ + originalStartLineNumber: number, + originalEndLineNumber: number, + modifiedStartLineNumber: number, + modifiedEndLineNumber: number +]; + +export interface ITextEditorDiffInformation { + readonly documentVersion: number; + readonly original: URI | undefined; + readonly modified: URI | undefined; + readonly diff: readonly ITextEditorDiff[]; +} + +export function isTextEditorDiffInformationEqual( + uriIdentityService: IUriIdentityService, + diff1: ITextEditorDiffInformation | undefined, + diff2: ITextEditorDiffInformation | undefined): boolean { + return diff1?.documentVersion === diff2?.documentVersion && + uriIdentityService.extUri.isEqual(diff1?.original, diff2?.original) && + uriIdentityService.extUri.isEqual(diff1?.modified, diff2?.modified) && + equals(diff1?.diff, diff2?.diff, (a, b) => { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; + }); +} diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index f2ad98bdd17..818021ff0c1 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -75,8 +75,6 @@ export interface NativeParsedArgs { 'disable-extensions'?: boolean; 'disable-extension'?: string[]; // undefined or array of 1 or more 'list-extensions'?: boolean; - 'download-extension'?: string[]; - 'location'?: string; 'show-versions'?: boolean; 'category'?: string; 'install-extension'?: string[]; // undefined or array of 1 or more diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 48809d5e894..379e327c646 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -97,7 +97,6 @@ export const OPTIONS: OptionDescriptions> = { 'list-extensions': { type: 'boolean', cat: 'e', description: localize('listExtensions', "List the installed extensions.") }, 'show-versions': { type: 'boolean', cat: 'e', description: localize('showVersions', "Show versions of installed extensions, when using --list-extensions.") }, 'category': { type: 'string', allowEmptyValue: true, cat: 'e', description: localize('category', "Filters installed extensions by provided category, when using --list-extensions."), args: 'category' }, - 'download-extension': { type: 'string[]', cat: 'e', args: 'ext-id', description: localize('downloadExtension', "Downloads the extension VSIX that can be installable. The argument is an identifier of an extension that is '${publisher}.${name}'. To download a specific version provide '@${version}'. For example: 'vscode.csharp@1.2.3'. Should provide '--location' to specify the location to download the VSIX.") }, 'install-extension': { type: 'string[]', cat: 'e', args: 'ext-id | path', description: localize('installExtension', "Installs or updates an extension. The argument is either an extension id or a path to a VSIX. The identifier of an extension is '${publisher}.${name}'. Use '--force' argument to update to latest version. To install a specific version provide '@${version}'. For example: 'vscode.csharp@1.2.3'.") }, 'pre-release': { type: 'boolean', cat: 'e', description: localize('install prerelease', "Installs the pre-release version of the extension, when using --install-extension") }, 'uninstall-extension': { type: 'string[]', cat: 'e', args: 'ext-id', description: localize('uninstallExtension', "Uninstalls an extension.") }, @@ -164,7 +163,6 @@ export const OPTIONS: OptionDescriptions> = { 'file-chmod': { type: 'boolean' }, 'install-builtin-extension': { type: 'string[]' }, 'force': { type: 'boolean' }, - 'location': { type: 'string' }, 'do-not-sync': { type: 'boolean' }, 'trace': { type: 'boolean' }, 'trace-category-filter': { type: 'string' }, diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index b3ccbedc26e..28282587c55 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -909,10 +909,6 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi } query = query.withSortBy(SortBy.NoneOrRelevance); - } else if (options.ids) { - query = query.withFilter(FilterType.ExtensionId, ...options.ids); - } else if (options.names) { - query = query.withFilter(FilterType.ExtensionName, ...options.names); } else { query = query.withSortBy(SortBy.InstallCount); } diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index 6d0d2e93fde..42e8875e7cc 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -295,8 +295,7 @@ export const enum SortOrder { export interface IQueryOptions { text?: string; - ids?: string[]; - names?: string[]; + exclude?: string[]; pageSize?: number; sortBy?: SortBy; sortOrder?: SortOrder; diff --git a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts index 36a9901341a..69ba77c8c84 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts @@ -14,7 +14,6 @@ import { EXTENSION_IDENTIFIER_REGEX, IExtensionGalleryService, IExtensionInfo, I import { areSameExtensions, getExtensionId, getGalleryExtensionId, getIdAndVersion } from './extensionManagementUtil.js'; import { ExtensionType, EXTENSION_CATEGORIES, IExtensionManifest } from '../../extensions/common/extensions.js'; import { ILogger } from '../../log/common/log.js'; -import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; const notFound = (id: string) => localize('notFound', "Extension '{0}' not found.", id); @@ -29,7 +28,6 @@ export class ExtensionManagementCLI { protected readonly logger: ILogger, @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, - @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, ) { } protected get location(): string | undefined { @@ -72,42 +70,6 @@ export class ExtensionManagementCLI { } } - public async downloadExtensions(extensions: string[], target: URI): Promise { - if (!extensions.length) { - return; - } - - this.logger.info(localize('downloadingExtensions', "Downloading extensions...")); - - const extensionsInfo: IExtensionInfo[] = []; - for (const extension of extensions) { - const [id, version] = getIdAndVersion(extension); - extensionsInfo.push({ id, version: version !== 'prerelease' ? version : undefined, preRelease: version === 'prerelease' }); - } - - try { - const galleryExtensions = await this.extensionGalleryService.getExtensions(extensionsInfo, CancellationToken.None); - const targetPlatform = await this.extensionManagementService.getTargetPlatform(); - await Promise.allSettled(extensionsInfo.map(async extensionInfo => { - const galleryExtension = galleryExtensions.find(e => areSameExtensions(e.identifier, { id: extensionInfo.id })); - if (!galleryExtension) { - this.logger.error(`${notFound(extensionInfo.id)}\n${useId}`); - return; - } - const compatible = await this.extensionGalleryService.getCompatibleExtension(galleryExtension, !!extensionInfo.hasPreRelease, targetPlatform); - try { - await this.extensionGalleryService.download(compatible ?? galleryExtension, this.uriIdentityService.extUri.joinPath(target, `${galleryExtension.identifier.id}-${galleryExtension.version}.vsix`), InstallOperation.None); - this.logger.info(localize('successDownload', "Extension '{0}' was successfully downloaded.", extensionInfo.id)); - } catch (error) { - this.logger.error(localize('error while downloading extension', "Error while downloading extension '{0}': {1}", extensionInfo.id, getErrorMessage(error))); - } - })); - } catch (error) { - this.logger.error(localize('error while downloading extensions', "Error while downloading extensions: {0}", getErrorMessage(error))); - throw error; - } - } - public async installExtensions(extensions: (string | URI)[], builtinExtensions: (string | URI)[], installOptions: InstallOptions, force: boolean): Promise { const failed: string[] = []; diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index ad7d55b3b85..585272ac91d 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -409,7 +409,7 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi pinned: options.installGivenVersion ? true : !!options.pinned, source: 'vsix', }, - options.keepExisting ?? true, + isBoolean(options.keepExisting) ? !options.keepExisting : true, token); return { local }; } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 13aa3bc1d1b..27e5d94c455 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -238,10 +238,6 @@ const _allApiProposals = { languageStatusText: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.languageStatusText.d.ts', }, - lmTools: { - proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.lmTools.d.ts', - version: 15 - }, mappedEditsProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.mappedEditsProvider.d.ts', }, @@ -371,6 +367,9 @@ const _allApiProposals = { testRelatedCode: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testRelatedCode.d.ts', }, + textEditorDiffInformation: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts', + }, textSearchComplete2: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.textSearchComplete2.d.ts', }, @@ -401,6 +400,9 @@ const _allApiProposals = { tunnels: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.tunnels.d.ts', }, + valueSelectionInQuickPick: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.valueSelectionInQuickPick.d.ts', + }, workspaceTrust: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.workspaceTrust.d.ts', } diff --git a/src/vs/platform/layout/browser/zIndexRegistry.ts b/src/vs/platform/layout/browser/zIndexRegistry.ts index 671eb7754ec..3c11201f622 100644 --- a/src/vs/platform/layout/browser/zIndexRegistry.ts +++ b/src/vs/platform/layout/browser/zIndexRegistry.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { clearNode, createCSSRule, createStyleSheet } from '../../../base/browser/dom.js'; +import { clearNode } from '../../../base/browser/dom.js'; +import { createCSSRule, createStyleSheet } from '../../../base/browser/domStylesheets.js'; import { RunOnceScheduler } from '../../../base/common/async.js'; export enum ZIndex { diff --git a/src/vs/platform/log/common/log.ts b/src/vs/platform/log/common/log.ts index 2471e0573d8..e503417a554 100644 --- a/src/vs/platform/log/common/log.ts +++ b/src/vs/platform/log/common/log.ts @@ -259,6 +259,13 @@ export abstract class AbstractLogger extends Disposable implements ILogger { return this.level !== LogLevel.Off && this.level <= level; } + protected canLog(level: LogLevel): boolean { + if (this._store.isDisposed) { + return false; + } + return this.checkLogLevel(level); + } + abstract trace(message: string, ...args: any[]): void; abstract debug(message: string, ...args: any[]): void; abstract info(message: string, ...args: any[]): void; @@ -269,8 +276,6 @@ export abstract class AbstractLogger extends Disposable implements ILogger { export abstract class AbstractMessageLogger extends AbstractLogger implements ILogger { - protected abstract log(level: LogLevel, message: string): void; - constructor(private readonly logAlways?: boolean) { super(); } @@ -280,32 +285,31 @@ export abstract class AbstractMessageLogger extends AbstractLogger implements IL } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { this.log(LogLevel.Trace, format([message, ...args], true)); } } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { this.log(LogLevel.Debug, format([message, ...args])); } } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { this.log(LogLevel.Info, format([message, ...args])); } } warn(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { this.log(LogLevel.Warning, format([message, ...args])); } } error(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { - + if (this.canLog(LogLevel.Error)) { if (message instanceof Error) { const array = Array.prototype.slice.call(arguments) as any[]; array[0] = message.stack; @@ -317,6 +321,8 @@ export abstract class AbstractMessageLogger extends AbstractLogger implements IL } flush(): void { } + + protected abstract log(level: LogLevel, message: string): void; } @@ -331,7 +337,7 @@ export class ConsoleMainLogger extends AbstractLogger implements ILogger { } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { if (this.useColors) { console.log(`\x1b[90m[main ${now()}]\x1b[0m`, message, ...args); } else { @@ -341,7 +347,7 @@ export class ConsoleMainLogger extends AbstractLogger implements ILogger { } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { if (this.useColors) { console.log(`\x1b[90m[main ${now()}]\x1b[0m`, message, ...args); } else { @@ -351,7 +357,7 @@ export class ConsoleMainLogger extends AbstractLogger implements ILogger { } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { if (this.useColors) { console.log(`\x1b[90m[main ${now()}]\x1b[0m`, message, ...args); } else { @@ -361,7 +367,7 @@ export class ConsoleMainLogger extends AbstractLogger implements ILogger { } warn(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { if (this.useColors) { console.warn(`\x1b[93m[main ${now()}]\x1b[0m`, message, ...args); } else { @@ -371,7 +377,7 @@ export class ConsoleMainLogger extends AbstractLogger implements ILogger { } error(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { + if (this.canLog(LogLevel.Error)) { if (this.useColors) { console.error(`\x1b[91m[main ${now()}]\x1b[0m`, message, ...args); } else { @@ -394,7 +400,7 @@ export class ConsoleLogger extends AbstractLogger implements ILogger { } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { if (this.useColors) { console.log('%cTRACE', 'color: #888', message, ...args); } else { @@ -404,7 +410,7 @@ export class ConsoleLogger extends AbstractLogger implements ILogger { } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { if (this.useColors) { console.log('%cDEBUG', 'background: #eee; color: #888', message, ...args); } else { @@ -414,7 +420,7 @@ export class ConsoleLogger extends AbstractLogger implements ILogger { } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { if (this.useColors) { console.log('%c INFO', 'color: #33f', message, ...args); } else { @@ -424,7 +430,7 @@ export class ConsoleLogger extends AbstractLogger implements ILogger { } warn(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { if (this.useColors) { console.log('%c WARN', 'color: #993', message, ...args); } else { @@ -434,7 +440,7 @@ export class ConsoleLogger extends AbstractLogger implements ILogger { } error(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { + if (this.canLog(LogLevel.Error)) { if (this.useColors) { console.log('%c ERR', 'color: #f33', message, ...args); } else { @@ -457,31 +463,31 @@ export class AdapterLogger extends AbstractLogger implements ILogger { } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { this.adapter.log(LogLevel.Trace, [this.extractMessage(message), ...args]); } } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { this.adapter.log(LogLevel.Debug, [this.extractMessage(message), ...args]); } } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { this.adapter.log(LogLevel.Info, [this.extractMessage(message), ...args]); } } warn(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { this.adapter.log(LogLevel.Warning, [this.extractMessage(message), ...args]); } } error(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { + if (this.canLog(LogLevel.Error)) { this.adapter.log(LogLevel.Error, [this.extractMessage(message), ...args]); } } @@ -491,7 +497,7 @@ export class AdapterLogger extends AbstractLogger implements ILogger { return msg; } - return toErrorMessage(msg, this.checkLogLevel(LogLevel.Trace)); + return toErrorMessage(msg, this.canLog(LogLevel.Trace)); } flush(): void { diff --git a/src/vs/platform/log/node/spdlogLog.ts b/src/vs/platform/log/node/spdlogLog.ts index b70bbdbd07f..76275056222 100644 --- a/src/vs/platform/log/node/spdlogLog.ts +++ b/src/vs/platform/log/node/spdlogLog.ts @@ -110,14 +110,10 @@ export class SpdLogLogger extends AbstractMessageLogger implements ILogger { } override flush(): void { - if (this._store.isDisposed) { - return; - } - if (this._logger) { - this._logger.flush(); + this.flushLogger(); } else { - this._loggerCreationPromise.then(() => this.flush()); + this._loggerCreationPromise.then(() => this.flushLogger()); } } @@ -130,6 +126,12 @@ export class SpdLogLogger extends AbstractMessageLogger implements ILogger { super.dispose(); } + private flushLogger(): void { + if (this._logger) { + this._logger.flush(); + } + } + private disposeLogger(): void { if (this._logger) { this._logger.drop(); diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index 817538e1d59..7b77f077c03 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../base/browser/dom.js'; +import * as domStylesheetsJs from '../../../base/browser/domStylesheets.js'; import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; import { ActionViewItem } from '../../../base/browser/ui/actionbar/actionViewItems.js'; import { Button } from '../../../base/browser/ui/button/button.js'; @@ -113,7 +114,7 @@ export class QuickInputController extends Disposable { container.tabIndex = -1; container.style.display = 'none'; - const styleSheet = dom.createStyleSheet(container); + const styleSheet = domStylesheetsJs.createStyleSheet(container); const titleBar = dom.append(container, $('.quick-input-titlebar')); diff --git a/src/vs/platform/quickinput/browser/quickInputUtils.ts b/src/vs/platform/quickinput/browser/quickInputUtils.ts index a3f611101aa..6f968ed4d96 100644 --- a/src/vs/platform/quickinput/browser/quickInputUtils.ts +++ b/src/vs/platform/quickinput/browser/quickInputUtils.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../base/browser/dom.js'; +import * as domStylesheetsJs from '../../../base/browser/domStylesheets.js'; import * as cssJs from '../../../base/browser/cssValue.js'; import { DomEmitter } from '../../../base/browser/event.js'; import { Event } from '../../../base/common/event.js'; @@ -34,8 +35,8 @@ function getIconClass(iconPath: { dark: URI; light?: URI } | undefined): string iconClass = iconPathToClass[key]; } else { iconClass = iconClassGenerator.nextId(); - dom.createCSSRule(`.${iconClass}, .hc-light .${iconClass}`, `background-image: ${cssJs.asCSSUrl(iconPath.light || iconPath.dark)}`); - dom.createCSSRule(`.vs-dark .${iconClass}, .hc-black .${iconClass}`, `background-image: ${cssJs.asCSSUrl(iconPath.dark)}`); + domStylesheetsJs.createCSSRule(`.${iconClass}, .hc-light .${iconClass}`, `background-image: ${cssJs.asCSSUrl(iconPath.light || iconPath.dark)}`); + domStylesheetsJs.createCSSRule(`.vs-dark .${iconClass}, .hc-black .${iconClass}`, `background-image: ${cssJs.asCSSUrl(iconPath.dark)}`); iconPathToClass[key] = iconClass; } diff --git a/src/vs/platform/telemetry/test/common/telemetryLogAppender.test.ts b/src/vs/platform/telemetry/test/common/telemetryLogAppender.test.ts index 91721dc7bd5..eb72191f928 100644 --- a/src/vs/platform/telemetry/test/common/telemetryLogAppender.test.ts +++ b/src/vs/platform/telemetry/test/common/telemetryLogAppender.test.ts @@ -21,31 +21,31 @@ class TestTelemetryLogger extends AbstractLogger implements ILogger { } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { this.logs.push(message + JSON.stringify(args)); } } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { this.logs.push(message); } } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { this.logs.push(message); } } warn(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { this.logs.push(message.toString()); } } error(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { + if (this.canLog(LogLevel.Error)) { this.logs.push(message); } } diff --git a/src/vs/platform/theme/browser/iconsStyleSheet.ts b/src/vs/platform/theme/browser/iconsStyleSheet.ts index 9133d54c5c7..7215f066d45 100644 --- a/src/vs/platform/theme/browser/iconsStyleSheet.ts +++ b/src/vs/platform/theme/browser/iconsStyleSheet.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { asCSSPropertyValue, asCSSUrl } from '../../../base/browser/cssValue.js'; +import * as css from '../../../base/browser/cssValue.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../base/common/themables.js'; @@ -11,7 +11,7 @@ import { getIconRegistry, IconContribution, IconFontDefinition } from '../common import { IProductIconTheme, IThemeService } from '../common/themeService.js'; export interface IIconsStyleSheet extends IDisposable { - getCSS(): string; + getCSS(): css.CssFragment; readonly onDidChange: Event; } @@ -28,12 +28,12 @@ export function getIconsStyleSheet(themeService: IThemeService | undefined): IIc return { dispose: () => disposable.dispose(), onDidChange: onDidChangeEmmiter.event, - getCSS() { + getCSS(): css.CssFragment { const productIconTheme = themeService ? themeService.getProductIconTheme() : new UnthemedProductIconTheme(); const usedFontIds: { [id: string]: IconFontDefinition } = {}; - const rules: string[] = []; - const rootAttribs: string[] = []; + const rules = new css.Builder(); + const rootAttribs = new css.Builder(); for (const contribution of iconRegistry.getIcons()) { const definition = productIconTheme.getIcon(contribution); if (!definition) { @@ -41,30 +41,34 @@ export function getIconsStyleSheet(themeService: IThemeService | undefined): IIc } const fontContribution = definition.font; - const fontFamilyVar = `--vscode-icon-${contribution.id}-font-family`; - const contentVar = `--vscode-icon-${contribution.id}-content`; + const fontFamilyVar = css.inline`--vscode-icon-${css.className(contribution.id)}-font-family`; + const contentVar = css.inline`--vscode-icon-${css.className(contribution.id)}-content`; if (fontContribution) { usedFontIds[fontContribution.id] = fontContribution.definition; rootAttribs.push( - `${fontFamilyVar}: ${asCSSPropertyValue(fontContribution.id)};`, - `${contentVar}: '${definition.fontCharacter}';`, + css.inline`${fontFamilyVar}: ${css.stringValue(fontContribution.id)};`, + css.inline`${contentVar}: ${css.stringValue(definition.fontCharacter)};`, ); - rules.push(`.codicon-${contribution.id}:before { content: '${definition.fontCharacter}'; font-family: ${asCSSPropertyValue(fontContribution.id)}; }`); + rules.push(css.inline`.codicon-${css.className(contribution.id)}:before { content: ${css.stringValue(definition.fontCharacter)}; font-family: ${css.stringValue(fontContribution.id)}; }`); } else { - rootAttribs.push(`${contentVar}: '${definition.fontCharacter}'; ${fontFamilyVar}: 'codicon';`); - rules.push(`.codicon-${contribution.id}:before { content: '${definition.fontCharacter}'; }`); + rootAttribs.push(css.inline`${contentVar}: ${css.stringValue(definition.fontCharacter)}; ${fontFamilyVar}: 'codicon';`); + rules.push(css.inline`.codicon-${css.className(contribution.id)}:before { content: ${css.stringValue(definition.fontCharacter)}; }`); } } for (const id in usedFontIds) { const definition = usedFontIds[id]; - const fontWeight = definition.weight ? `font-weight: ${definition.weight};` : ''; - const fontStyle = definition.style ? `font-style: ${definition.style};` : ''; - const src = definition.src.map(l => `${asCSSUrl(l.location)} format('${l.format}')`).join(', '); - rules.push(`@font-face { src: ${src}; font-family: ${asCSSPropertyValue(id)};${fontWeight}${fontStyle} font-display: block; }`); + const fontWeight = definition.weight ? css.inline`font-weight: ${css.identValue(definition.weight)};` : css.inline``; + const fontStyle = definition.style ? css.inline`font-style: ${css.identValue(definition.style)};` : css.inline``; + + const src = new css.Builder(); + for (const l of definition.src) { + src.push(css.inline`${css.asCSSUrl(l.location)} format(${css.stringValue(l.format)})`); + } + rules.push(css.inline`@font-face { src: ${src.join(', ')}; font-family: ${css.stringValue(id)};${fontWeight}${fontStyle} font-display: block; }`); } - rules.push(`:root { ${rootAttribs.join(' ')} }`); + rules.push(css.inline`:root { ${rootAttribs.join(' ')} }`); return rules.join('\n'); } diff --git a/src/vs/platform/theme/common/iconRegistry.ts b/src/vs/platform/theme/common/iconRegistry.ts index 279b30ba113..5dee6ce44d6 100644 --- a/src/vs/platform/theme/common/iconRegistry.ts +++ b/src/vs/platform/theme/common/iconRegistry.ts @@ -26,15 +26,15 @@ export const Extensions = { export type IconDefaults = ThemeIcon | IconDefinition; export interface IconDefinition { - font?: IconFontContribution; // undefined for the default font (codicon) - fontCharacter: string; + readonly font?: IconFontContribution; // undefined for the default font (codicon) + readonly fontCharacter: string; } export interface IconContribution { readonly id: string; description: string | undefined; - deprecationMessage?: string; + readonly deprecationMessage?: string; readonly defaults: IconDefaults; } diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index cd1091150db..42517aa71b3 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -276,7 +276,7 @@ class ServerLogger extends AbstractLogger { } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { if (this.useColors) { console.log(`\x1b[90m[${now()}]\x1b[0m`, message, ...args); } else { @@ -286,7 +286,7 @@ class ServerLogger extends AbstractLogger { } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { if (this.useColors) { console.log(`\x1b[90m[${now()}]\x1b[0m`, message, ...args); } else { @@ -296,7 +296,7 @@ class ServerLogger extends AbstractLogger { } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { if (this.useColors) { console.log(`\x1b[90m[${now()}]\x1b[0m`, message, ...args); } else { @@ -306,7 +306,7 @@ class ServerLogger extends AbstractLogger { } warn(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { if (this.useColors) { console.warn(`\x1b[93m[${now()}]\x1b[0m`, message, ...args); } else { @@ -316,7 +316,7 @@ class ServerLogger extends AbstractLogger { } error(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { + if (this.canLog(LogLevel.Error)) { if (this.useColors) { console.error(`\x1b[91m[${now()}]\x1b[0m`, message, ...args); } else { diff --git a/src/vs/workbench/api/browser/mainThreadAuthentication.ts b/src/vs/workbench/api/browser/mainThreadAuthentication.ts index d0318c71993..081c5f80570 100644 --- a/src/vs/workbench/api/browser/mainThreadAuthentication.ts +++ b/src/vs/workbench/api/browser/mainThreadAuthentication.ts @@ -71,6 +71,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu private readonly _proxy: ExtHostAuthenticationShape; private readonly _registrations = this._register(new DisposableMap()); + private _sentProviderUsageEvents = new Set(); constructor( extHostContext: IExtHostContext, @@ -314,6 +315,11 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu } private sendProviderUsageTelemetry(extensionId: string, providerId: string): void { + const key = `${extensionId}|${providerId}`; + if (this._sentProviderUsageEvents.has(key)) { + return; + } + this._sentProviderUsageEvents.add(key); type AuthProviderUsageClassification = { owner: 'TylerLeonhardt'; comment: 'Used to see which extensions are using which providers'; diff --git a/src/vs/workbench/api/browser/mainThreadCLICommands.ts b/src/vs/workbench/api/browser/mainThreadCLICommands.ts index eaf95ea867c..be73afd9fd4 100644 --- a/src/vs/workbench/api/browser/mainThreadCLICommands.ts +++ b/src/vs/workbench/api/browser/mainThreadCLICommands.ts @@ -18,7 +18,6 @@ import { ServiceCollection } from '../../../platform/instantiation/common/servic import { ILabelService } from '../../../platform/label/common/label.js'; import { AbstractMessageLogger, ILogger, LogLevel } from '../../../platform/log/common/log.js'; import { IOpenerService } from '../../../platform/opener/common/opener.js'; -import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIdentity.js'; import { IOpenWindowOptions, IWindowOpenable } from '../../../platform/window/common/window.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; import { IExtensionManagementServerService } from '../../services/extensionManagement/common/extensionManagement.js'; @@ -104,12 +103,11 @@ class RemoteExtensionManagementCLI extends ExtensionManagementCLI { logger: ILogger, @IExtensionManagementService extensionManagementService: IExtensionManagementService, @IExtensionGalleryService extensionGalleryService: IExtensionGalleryService, - @IUriIdentityService uriIdentityService: IUriIdentityService, @ILabelService labelService: ILabelService, @IWorkbenchEnvironmentService envService: IWorkbenchEnvironmentService, @IExtensionManifestPropertiesService private readonly _extensionManifestPropertiesService: IExtensionManifestPropertiesService, ) { - super(logger, extensionManagementService, extensionGalleryService, uriIdentityService); + super(logger, extensionManagementService, extensionGalleryService); const remoteAuthority = envService.remoteAuthority; this._location = remoteAuthority ? labelService.getHostLabel(Schemas.vscodeRemote, remoteAuthority) : undefined; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 5d2c2df223f..cf451338375 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -25,7 +25,7 @@ import { IChatWidgetService } from '../../contrib/chat/browser/chat.js'; import { ChatInputPart } from '../../contrib/chat/browser/chatInputPart.js'; import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/chat/browser/contrib/chatDynamicVariables.js'; import { ChatAgentLocation, IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../contrib/chat/common/chatAgents.js'; -import { IChatEditingService } from '../../contrib/chat/common/chatEditingService.js'; +import { IChatEditingService, IChatRelatedFileProviderMetadata } from '../../contrib/chat/common/chatEditingService.js'; import { ChatRequestAgentPart } from '../../contrib/chat/common/chatParserTypes.js'; import { ChatRequestParser } from '../../contrib/chat/common/chatRequestParser.js'; import { IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatProgress, IChatService, IChatTask, IChatWarningMessage } from '../../contrib/chat/common/chatService.js'; @@ -349,8 +349,9 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._chatParticipantDetectionProviders.deleteAndDispose(handle); } - $registerRelatedFilesProvider(handle: number): void { + $registerRelatedFilesProvider(handle: number, metadata: IChatRelatedFileProviderMetadata): void { this._chatRelatedFilesProviders.set(handle, this._chatEditingService.registerRelatedFilesProvider(handle, { + description: metadata.description, provideRelatedFiles: async (request, token) => { return (await this._proxy.$provideRelatedFiles(handle, request, token))?.map((v) => ({ uri: URI.from(v.uri), description: v.description })) ?? []; } diff --git a/src/vs/workbench/api/browser/mainThreadDocumentsAndEditors.ts b/src/vs/workbench/api/browser/mainThreadDocumentsAndEditors.ts index c7a3dcea721..4f5e72a58f7 100644 --- a/src/vs/workbench/api/browser/mainThreadDocumentsAndEditors.ts +++ b/src/vs/workbench/api/browser/mainThreadDocumentsAndEditors.ts @@ -32,6 +32,7 @@ import { diffSets, diffMaps } from '../../../base/common/collections.js'; import { IPaneCompositePartService } from '../../services/panecomposite/browser/panecomposite.js'; import { ViewContainerLocation } from '../../common/views.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { IDirtyDiffModelService } from '../../contrib/scm/browser/diff.js'; class TextEditorSnapshot { @@ -296,14 +297,15 @@ export class MainThreadDocumentsAndEditors { @IUriIdentityService uriIdentityService: IUriIdentityService, @IClipboardService private readonly _clipboardService: IClipboardService, @IPathService pathService: IPathService, - @IConfigurationService configurationService: IConfigurationService + @IConfigurationService configurationService: IConfigurationService, + @IDirtyDiffModelService dirtyDiffModelService: IDirtyDiffModelService ) { this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostDocumentsAndEditors); this._mainThreadDocuments = this._toDispose.add(new MainThreadDocuments(extHostContext, this._modelService, this._textFileService, fileService, textModelResolverService, environmentService, uriIdentityService, workingCopyFileService, pathService)); extHostContext.set(MainContext.MainThreadDocuments, this._mainThreadDocuments); - this._mainThreadEditors = this._toDispose.add(new MainThreadTextEditors(this, extHostContext, codeEditorService, this._editorService, this._editorGroupService, configurationService)); + this._mainThreadEditors = this._toDispose.add(new MainThreadTextEditors(this, extHostContext, codeEditorService, this._editorService, this._editorGroupService, configurationService, dirtyDiffModelService, uriIdentityService)); extHostContext.set(MainContext.MainThreadTextEditors, this._mainThreadEditors); // It is expected that the ctor of the state computer calls our `_onDelta`. diff --git a/src/vs/workbench/api/browser/mainThreadEditors.ts b/src/vs/workbench/api/browser/mainThreadEditors.ts index a51ada95deb..51816c7afdf 100644 --- a/src/vs/workbench/api/browser/mainThreadEditors.ts +++ b/src/vs/workbench/api/browser/mainThreadEditors.ts @@ -13,7 +13,7 @@ import { ISelection } from '../../../editor/common/core/selection.js'; import { IDecorationOptions, IDecorationRenderOptions } from '../../../editor/common/editorCommon.js'; import { ISingleEditOperation } from '../../../editor/common/core/editOperation.js'; import { CommandsRegistry } from '../../../platform/commands/common/commands.js'; -import { ITextEditorOptions, IResourceEditorInput, EditorActivation, EditorResolution } from '../../../platform/editor/common/editor.js'; +import { ITextEditorOptions, IResourceEditorInput, EditorActivation, EditorResolution, ITextEditorDiffInformation, isTextEditorDiffInformationEqual, ITextEditorDiff } from '../../../platform/editor/common/editor.js'; import { ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { MainThreadTextEditor } from './mainThreadEditor.js'; import { ExtHostContext, ExtHostEditorsShape, IApplyEditsOptions, ITextDocumentShowOptions, ITextEditorConfigurationUpdate, ITextEditorPositionData, IUndoStopOptions, MainThreadTextEditorsShape, TextEditorRevealType } from '../common/extHost.protocol.js'; @@ -29,6 +29,9 @@ import { IEditorControl } from '../../common/editor.js'; import { getCodeEditor, ICodeEditor } from '../../../editor/browser/editorBrowser.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { DirtyDiffContribution } from '../../contrib/scm/browser/dirtydiffDecorator.js'; +import { IDirtyDiffModelService } from '../../contrib/scm/browser/diff.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable, observableFromEvent } from '../../../base/common/observable.js'; +import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIdentity.js'; export interface IMainThreadEditorLocator { getEditor(id: string): MainThreadTextEditor | undefined; @@ -53,7 +56,9 @@ export class MainThreadTextEditors implements MainThreadTextEditorsShape { @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, @IEditorService private readonly _editorService: IEditorService, @IEditorGroupsService private readonly _editorGroupService: IEditorGroupsService, - @IConfigurationService private readonly _configurationService: IConfigurationService + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IDirtyDiffModelService private readonly _dirtyDiffModelService: IDirtyDiffModelService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService ) { this._instanceId = String(++MainThreadTextEditors.INSTANCE_COUNT); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostEditors); @@ -87,6 +92,12 @@ export class MainThreadTextEditors implements MainThreadTextEditorsShape { this._proxy.$acceptEditorPropertiesChanged(id, data); })); + const diffInformationObs = this._getTextEditorDiffInformation(textEditor); + toDispose.push(autorun(reader => { + const diffInformation = diffInformationObs.read(reader); + this._proxy.$acceptEditorDiffInformation(id, diffInformation); + })); + this._textEditorsListenersMap[id] = toDispose; } @@ -116,6 +127,75 @@ export class MainThreadTextEditors implements MainThreadTextEditorsShape { return result; } + private _getTextEditorDiffInformation(textEditor: MainThreadTextEditor): IObservable { + const codeEditor = textEditor.getCodeEditor(); + if (!codeEditor) { + return constObservable(undefined); + } + + // Check if the TextModel belongs to a diff editor + const diffEditors = this._codeEditorService.listDiffEditors(); + const [diffEditor] = diffEditors.filter(d => + d.getOriginalEditor().getId() === codeEditor.getId() || + d.getModifiedEditor().getId() === codeEditor.getId()); + + const codeEditorTextModelObs = !diffEditor ? + observableFromEvent(this, codeEditor.onDidChangeModel, () => codeEditor.getModel()) : + observableFromEvent(this, diffEditor.onDidChangeModel, () => diffEditor.getModel()?.modified); + + const dirtyDiffModelObs = derived(reader => { + const codeEditorTextModel = codeEditorTextModelObs.read(reader); + return codeEditorTextModel ? this._dirtyDiffModelService.getOrCreateModel(codeEditorTextModel.uri) : undefined; + }); + + const scmQuickDiffChangesObs = derived(reader => { + const dirtyDiffModel = dirtyDiffModelObs.read(reader); + if (!dirtyDiffModel) { + return constObservable(undefined); + } + + return observableFromEvent(this, dirtyDiffModel.onDidChange, e => { + const scmQuickDiff = dirtyDiffModel.quickDiffs.find(diff => diff.isSCM === true); + if (!e || !scmQuickDiff) { + return undefined; + } + + return { + originalResource: scmQuickDiff.originalResource, + changes: e.changes + .filter(change => change.label === scmQuickDiff.label) + .map(change => change.change) + }; + }); + }); + + return derivedOpts({ + owner: this, + equalsFn: (diff1, diff2) => isTextEditorDiffInformationEqual(this._uriIdentityService, diff1, diff2) + }, reader => { + const codeEditorTextModel = codeEditorTextModelObs.read(reader); + const scmQuickDiffChanges = scmQuickDiffChangesObs.read(reader).read(reader); + if (!codeEditorTextModel || !scmQuickDiffChanges) { + return undefined; + } + + const diff: ITextEditorDiff[] = scmQuickDiffChanges.changes + .map(change => [ + change.originalStartLineNumber, + change.originalEndLineNumber, + change.modifiedStartLineNumber, + change.modifiedEndLineNumber + ]); + + return { + documentVersion: codeEditorTextModel.getVersionId(), + original: scmQuickDiffChanges.originalResource, + modified: codeEditorTextModel.uri, + diff + }; + }); + } + // --- from extension host process async $tryShowTextDocument(resource: UriComponents, options: ITextDocumentShowOptions): Promise { diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index e45e567775c..3fa10161b9a 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -132,7 +132,8 @@ const defaultConfigurationExtPoint = ExtensionsRegistry.registerExtensionPoint { @@ -195,7 +196,8 @@ const configurationExtPoint = ExtensionsRegistry.registerExtensionPoint = new ExtensionIdentifierMap(); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index cd7ad1dd50d..ef7f6098350 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -723,6 +723,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I onDidChangeTextEditorViewColumn(listener, thisArg?, disposables?) { return _asExtensionEvent(extHostEditors.onDidChangeTextEditorViewColumn)(listener, thisArg, disposables); }, + onDidChangeTextEditorDiffInformation(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'textEditorDiffInformation'); + return _asExtensionEvent(extHostEditors.onDidChangeTextEditorDiffInformation)(listener, thisArg, disposables); + }, onDidCloseTerminal(listener, thisArg?, disposables?) { return _asExtensionEvent(extHostTerminalService.onDidCloseTerminal)(listener, thisArg, disposables); }, @@ -1666,6 +1670,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I TextEdit: extHostTypes.TextEdit, SnippetTextEdit: extHostTypes.SnippetTextEdit, TextEditorCursorStyle: TextEditorCursorStyle, + TextEditorDiffKind: extHostTypes.TextEditorDiffKind, TextEditorLineNumbersStyle: extHostTypes.TextEditorLineNumbersStyle, TextEditorRevealType: extHostTypes.TextEditorRevealType, TextEditorSelectionChangeKind: extHostTypes.TextEditorSelectionChangeKind, @@ -1705,6 +1710,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I NotebookEditorRevealType: extHostTypes.NotebookEditorRevealType, NotebookCellOutput: extHostTypes.NotebookCellOutput, NotebookCellOutputItem: extHostTypes.NotebookCellOutputItem, + CellErrorStackFrame: extHostTypes.CellErrorStackFrame, NotebookCellStatusBarItem: extHostTypes.NotebookCellStatusBarItem, NotebookControllerAffinity: extHostTypes.NotebookControllerAffinity, NotebookControllerAffinity2: extHostTypes.NotebookControllerAffinity2, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 4f7fe5e918d..97d9c184283 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -52,7 +52,7 @@ import { IRevealOptions, ITreeItem, IViewBadge } from '../../common/views.js'; import { CallHierarchyItem } from '../../contrib/callHierarchy/common/callHierarchy.js'; import { ChatAgentLocation, IChatAgentMetadata, IChatAgentRequest, IChatAgentResult, IChatWelcomeMessageContent } from '../../contrib/chat/common/chatAgents.js'; import { ICodeMapperRequest, ICodeMapperResult } from '../../contrib/chat/common/chatCodeMapperService.js'; -import { IChatRelatedFile, IChatRequestDraft } from '../../contrib/chat/common/chatEditingService.js'; +import { IChatRelatedFile, IChatRelatedFileProviderMetadata as IChatRelatedFilesProviderMetadata, IChatRequestDraft } from '../../contrib/chat/common/chatEditingService.js'; import { IChatProgressHistoryResponseContent } from '../../contrib/chat/common/chatModel.js'; import { IChatContentInlineReference, IChatFollowup, IChatProgress, IChatResponseErrorDetails, IChatTask, IChatTaskDto, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService.js'; import { IChatRequestVariableValue, IChatVariableData, IChatVariableResolverProgress } from '../../contrib/chat/common/chatVariables.js'; @@ -1278,7 +1278,7 @@ export interface MainThreadChatAgentsShape2 extends IDisposable { $registerAgent(handle: number, extension: ExtensionIdentifier, id: string, metadata: IExtensionChatAgentMetadata, dynamicProps: IDynamicChatAgentProps | undefined): void; $registerChatParticipantDetectionProvider(handle: number): void; $unregisterChatParticipantDetectionProvider(handle: number): void; - $registerRelatedFilesProvider(handle: number): void; + $registerRelatedFilesProvider(handle: number, metadata: IChatRelatedFilesProviderMetadata): void; $unregisterRelatedFilesProvider(handle: number): void; $registerAgentCompletionsProvider(handle: number, id: string, triggerCharacters: string[]): void; $unregisterAgentCompletionsProvider(handle: number, id: string): void; @@ -1820,6 +1820,21 @@ export interface ITextEditorAddData { export interface ITextEditorPositionData { [id: string]: EditorGroupColumn; } + +export type ITextEditorDiff = [ + originalStartLineNumber: number, + originalEndLineNumber: number, + modifiedStartLineNumber: number, + modifiedEndLineNumber: number +]; + +export interface ITextEditorDiffInformation { + readonly documentVersion: number; + readonly original: UriComponents | undefined; + readonly modified: UriComponents | undefined; + readonly diff: readonly ITextEditorDiff[]; +} + export interface IEditorPropertiesChangeData { options: IResolvedTextEditorConfiguration | null; selections: ISelectionChangeEvent | null; @@ -1833,6 +1848,7 @@ export interface ISelectionChangeEvent { export interface ExtHostEditorsShape { $acceptEditorPropertiesChanged(id: string, props: IEditorPropertiesChangeData): void; $acceptEditorPositionData(data: ITextEditorPositionData): void; + $acceptEditorDiffInformation(id: string, diffInformation: ITextEditorDiffInformation | undefined): void; } export interface IDocumentsAndEditorsDelta { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 108de1f9cf4..14d09843895 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -369,7 +369,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS registerRelatedFilesProvider(extension: IExtensionDescription, provider: vscode.ChatRelatedFilesProvider, metadata: vscode.ChatRelatedFilesProviderMetadata): vscode.Disposable { const handle = ExtHostChatAgents2._relatedFilesProviderIdPool++; this._relatedFilesProviders.set(handle, new ExtHostRelatedFilesProvider(extension, provider)); - this._proxy.$registerRelatedFilesProvider(handle); + this._proxy.$registerRelatedFilesProvider(handle, metadata); return toDisposable(() => { this._relatedFilesProviders.delete(handle); this._proxy.$unregisterRelatedFilesProvider(handle); diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index 6313345cb97..c39b9347e70 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -1554,6 +1554,14 @@ class InlineEditAdapter { rejectCommand = this._commands.toInternal(result.rejected, disposableStore); } + let shownCommand: languages.Command | undefined = undefined; + if (result.shown) { + if (!disposableStore) { + disposableStore = new DisposableStore(); + } + shownCommand = this._commands.toInternal(result.shown, disposableStore); + } + if (!disposableStore) { disposableStore = new DisposableStore(); } @@ -1563,6 +1571,7 @@ class InlineEditAdapter { range: typeConvert.Range.from(result.range), accepted: acceptCommand, rejected: rejectCommand, + shown: shownCommand, commands: result.commands?.map(c => this._commands.toInternal(c, disposableStore)), }; diff --git a/src/vs/workbench/api/common/extHostNotebookKernels.ts b/src/vs/workbench/api/common/extHostNotebookKernels.ts index 584a4508003..9c923a42745 100644 --- a/src/vs/workbench/api/common/extHostNotebookKernels.ts +++ b/src/vs/workbench/api/common/extHostNotebookKernels.ts @@ -721,17 +721,7 @@ class NotebookCellExecutionTask extends Disposable { // so we use updateSoon and immediately flush. that._collector.flush(); - const error = executionError ? { - message: executionError.message, - stack: executionError.stack, - location: executionError?.location ? { - startLineNumber: executionError.location.start.line, - startColumn: executionError.location.start.character, - endLineNumber: executionError.location.end.line, - endColumn: executionError.location.end.character - } : undefined, - uri: executionError.uri - } : undefined; + const error = createSerializeableError(executionError); that._proxy.$completeExecution(that._handle, new SerializableObjectWithBuffers({ runEndTime: endTime, @@ -769,6 +759,31 @@ class NotebookCellExecutionTask extends Disposable { } } +function createSerializeableError(executionError: vscode.CellExecutionError | undefined) { + const convertRange = (range: vscode.Range | undefined) => (range ? { + startLineNumber: range.start.line, + startColumn: range.start.character, + endLineNumber: range.end.line, + endColumn: range.end.character + } : undefined); + + const convertStackFrame = (frame: vscode.CellErrorStackFrame) => ({ + uri: frame.uri, + position: frame.position, + label: frame.label + }); + + const error = executionError ? { + name: executionError.name, + message: executionError.message, + stack: executionError.stack instanceof Array + ? executionError.stack.map(frame => convertStackFrame(frame)) + : executionError.stack, + location: convertRange(executionError.location), + uri: executionError.uri + } : undefined; + return error; +} enum NotebookExecutionTaskState { Init, diff --git a/src/vs/workbench/api/common/extHostQuickOpen.ts b/src/vs/workbench/api/common/extHostQuickOpen.ts index a5194dce8ef..cb17214f2a4 100644 --- a/src/vs/workbench/api/common/extHostQuickOpen.ts +++ b/src/vs/workbench/api/common/extHostQuickOpen.ts @@ -283,6 +283,7 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx private _busy = false; private _ignoreFocusOut = true; private _value = ''; + private _valueSelection: readonly [number, number] | undefined = undefined; private _placeholder: string | undefined; private _buttons: QuickInputButton[] = []; private _handlesToButtons = new Map(); @@ -367,6 +368,15 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx this.update({ value }); } + get valueSelection() { + return this._valueSelection; + } + + set valueSelection(valueSelection: readonly [number, number] | undefined) { + this._valueSelection = valueSelection; + this.update({ valueSelection }); + } + get placeholder() { return this._placeholder; } @@ -713,7 +723,6 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx private _password = false; private _prompt: string | undefined; - private _valueSelection: readonly [number, number] | undefined; private _validationMessage: string | InputBoxValidationMessage | undefined; constructor(extension: IExtensionDescription, onDispose: () => void) { @@ -739,15 +748,6 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx this.update({ prompt }); } - get valueSelection() { - return this._valueSelection; - } - - set valueSelection(valueSelection: readonly [number, number] | undefined) { - this._valueSelection = valueSelection; - this.update({ valueSelection }); - } - get validationMessage() { return this._validationMessage; } diff --git a/src/vs/workbench/api/common/extHostTextEditor.ts b/src/vs/workbench/api/common/extHostTextEditor.ts index cba39eb86b5..df874f990a3 100644 --- a/src/vs/workbench/api/common/extHostTextEditor.ts +++ b/src/vs/workbench/api/common/extHostTextEditor.ts @@ -412,6 +412,7 @@ export class ExtHostTextEditor { private _viewColumn: vscode.ViewColumn | undefined; private _disposed: boolean = false; private _hasDecorationsForKey = new Set(); + private _diffInformation: vscode.TextEditorDiffInformation | undefined; readonly value: vscode.TextEditor; @@ -465,6 +466,9 @@ export class ExtHostTextEditor { set visibleRanges(_value: Range[]) { throw new ReadonlyError('visibleRanges'); }, + get diffInformation() { + return that._diffInformation; + }, // --- options get options(): vscode.TextEditorOptions { return that._options.value; @@ -600,6 +604,11 @@ export class ExtHostTextEditor { this._selections = selections; } + _acceptDiffInformation(diffInformation: vscode.TextEditorDiffInformation | undefined): void { + ok(!this._disposed); + this._diffInformation = diffInformation; + } + private async _trySetSelection(): Promise { const selection = this._selections.map(TypeConverters.Selection.from); await this._runOnProxy(() => this._proxy.$trySetSelections(this.id, selection)); diff --git a/src/vs/workbench/api/common/extHostTextEditors.ts b/src/vs/workbench/api/common/extHostTextEditors.ts index 178c6c5bea9..a519961cbe2 100644 --- a/src/vs/workbench/api/common/extHostTextEditors.ts +++ b/src/vs/workbench/api/common/extHostTextEditors.ts @@ -6,12 +6,13 @@ import * as arrays from '../../../base/common/arrays.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; -import { ExtHostEditorsShape, IEditorPropertiesChangeData, IMainContext, ITextDocumentShowOptions, ITextEditorPositionData, MainContext, MainThreadTextEditorsShape } from './extHost.protocol.js'; +import { ExtHostEditorsShape, IEditorPropertiesChangeData, IMainContext, ITextDocumentShowOptions, ITextEditorDiffInformation, ITextEditorPositionData, MainContext, MainThreadTextEditorsShape } from './extHost.protocol.js'; import { ExtHostDocumentsAndEditors } from './extHostDocumentsAndEditors.js'; import { ExtHostTextEditor, TextEditorDecorationType } from './extHostTextEditor.js'; import * as TypeConverters from './extHostTypeConverters.js'; -import { TextEditorSelectionChangeKind } from './extHostTypes.js'; +import { TextEditorSelectionChangeKind, TextEditorDiffKind } from './extHostTypes.js'; import * as vscode from 'vscode'; export class ExtHostEditors extends Disposable implements ExtHostEditorsShape { @@ -20,6 +21,7 @@ export class ExtHostEditors extends Disposable implements ExtHostEditorsShape { private readonly _onDidChangeTextEditorOptions = new Emitter(); private readonly _onDidChangeTextEditorVisibleRanges = new Emitter(); private readonly _onDidChangeTextEditorViewColumn = new Emitter(); + private readonly _onDidChangeTextEditorDiffInformation = new Emitter(); private readonly _onDidChangeActiveTextEditor = new Emitter(); private readonly _onDidChangeVisibleTextEditors = new Emitter(); @@ -27,6 +29,7 @@ export class ExtHostEditors extends Disposable implements ExtHostEditorsShape { readonly onDidChangeTextEditorOptions: Event = this._onDidChangeTextEditorOptions.event; readonly onDidChangeTextEditorVisibleRanges: Event = this._onDidChangeTextEditorVisibleRanges.event; readonly onDidChangeTextEditorViewColumn: Event = this._onDidChangeTextEditorViewColumn.event; + readonly onDidChangeTextEditorDiffInformation: Event = this._onDidChangeTextEditorDiffInformation.event; readonly onDidChangeActiveTextEditor: Event = this._onDidChangeActiveTextEditor.event; readonly onDidChangeVisibleTextEditors: Event = this._onDidChangeVisibleTextEditors.event; @@ -157,6 +160,53 @@ export class ExtHostEditors extends Disposable implements ExtHostEditorsShape { } } + $acceptEditorDiffInformation(id: string, diffInformation: ITextEditorDiffInformation | undefined): void { + const textEditor = this._extHostDocumentsAndEditors.getEditor(id); + if (!textEditor) { + throw new Error('unknown text editor'); + } + + if (!diffInformation) { + textEditor._acceptDiffInformation(undefined); + this._onDidChangeTextEditorDiffInformation.fire({ + textEditor: textEditor.value, + diffInformation: undefined + }); + return; + } + + const original = URI.revive(diffInformation.original); + const modified = URI.revive(diffInformation.modified); + + const diff = diffInformation.diff.map(diff => { + const [originalStartLineNumber, originalEndLineNumber, modifiedStartLineNumber, modifiedEndLineNumber] = diff; + + const kind = originalEndLineNumber === 0 ? TextEditorDiffKind.Addition : + modifiedEndLineNumber === 0 ? TextEditorDiffKind.Deletion : TextEditorDiffKind.Modification; + + return { + originalStartLineNumber, + originalEndLineNumber, + modifiedStartLineNumber, + modifiedEndLineNumber, + kind + } satisfies vscode.TextEditorDiff; + }); + + const result = Object.freeze({ + documentVersion: diffInformation.documentVersion, + original, + modified, + diff + }); + + textEditor._acceptDiffInformation(result); + this._onDidChangeTextEditorDiffInformation.fire({ + textEditor: textEditor.value, + diffInformation: result + }); + } + getDiffInformation(id: string): Promise { return Promise.resolve(this._proxy.$getDiffInformation(id)); } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index f6b1851de01..877d24ca01f 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -1909,6 +1909,12 @@ export enum TextEditorSelectionChangeKind { Command = 3 } +export enum TextEditorDiffKind { + Addition = 1, + Deletion = 2, + Modification = 3 +} + export enum TextDocumentChangeReason { Undo = 1, Redo = 2, @@ -3930,6 +3936,19 @@ export class NotebookCellOutput { } } +export class CellErrorStackFrame { + /** + * @param label The name of the stack frame + * @param file The file URI of the stack frame + * @param position The position of the stack frame within the file + */ + constructor( + public label: string, + public uri?: vscode.Uri, + public position?: Position, + ) { } +} + export enum NotebookCellKind { Markup = 1, Code = 2 diff --git a/src/vs/workbench/api/test/browser/mainThreadDocumentsAndEditors.test.ts b/src/vs/workbench/api/test/browser/mainThreadDocumentsAndEditors.test.ts index 08a5f0682c6..cdebb67e526 100644 --- a/src/vs/workbench/api/test/browser/mainThreadDocumentsAndEditors.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadDocumentsAndEditors.test.ts @@ -10,7 +10,7 @@ import { TestConfigurationService } from '../../../../platform/configuration/tes import { ModelService } from '../../../../editor/common/services/modelService.js'; import { TestCodeEditorService } from '../../../../editor/test/browser/editorTestServices.js'; import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; -import { ExtHostDocumentsAndEditorsShape, IDocumentsAndEditorsDelta } from '../../common/extHost.protocol.js'; +import { IDocumentsAndEditorsDelta } from '../../common/extHost.protocol.js'; import { createTestCodeEditor, ITestCodeEditor } from '../../../../editor/test/browser/testCodeEditor.js'; import { mock } from '../../../../base/test/common/mock.js'; import { TestEditorService, TestEditorGroupsService, TestEnvironmentService, TestPathService } from '../../../test/browser/workbenchTestServices.js'; @@ -36,6 +36,8 @@ import { LanguageService } from '../../../../editor/common/services/languageServ import { ILanguageConfigurationService } from '../../../../editor/common/languages/languageConfigurationRegistry.js'; import { TestLanguageConfigurationService } from '../../../../editor/test/common/modes/testLanguageConfigurationService.js'; import { IUndoRedoService } from '../../../../platform/undoRedo/common/undoRedo.js'; +import { IDirtyDiffModelService } from '../../../contrib/scm/browser/diff.js'; +import { ITextEditorDiffInformation } from '../../../../platform/editor/common/editor.js'; suite('MainThreadDocumentsAndEditors', () => { @@ -94,8 +96,9 @@ suite('MainThreadDocumentsAndEditors', () => { }; new MainThreadDocumentsAndEditors( - SingleProxyRPCProtocol(new class extends mock() { - override $acceptDocumentsAndEditorsDelta(delta: IDocumentsAndEditorsDelta) { deltas.push(delta); } + SingleProxyRPCProtocol({ + $acceptDocumentsAndEditorsDelta: (delta: IDocumentsAndEditorsDelta) => { deltas.push(delta); }, + $acceptEditorDiffInformation: (id: string, diffInformation: ITextEditorDiffInformation | undefined) => { } }), modelService, textFileService, @@ -121,6 +124,11 @@ suite('MainThreadDocumentsAndEditors', () => { }, new TestPathService(), new TestConfigurationService(), + new class extends mock() { + override getOrCreateModel() { + return undefined; + } + } ); }); diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index bf0ca0f7b2d..468b22e85c8 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -11,7 +11,8 @@ import { DomEmitter } from '../../../base/browser/event.js'; import { Color } from '../../../base/common/color.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { IDisposable, toDisposable, dispose, DisposableStore, setDisposableTracker, DisposableTracker, DisposableInfo } from '../../../base/common/lifecycle.js'; -import { getDomNodePagePosition, createStyleSheet, createCSSRule, append, $, getActiveDocument, onDidRegisterWindow, getWindows } from '../../../base/browser/dom.js'; +import { getDomNodePagePosition, append, $, getActiveDocument, onDidRegisterWindow, getWindows } from '../../../base/browser/dom.js'; +import { createCSSRule, createStyleSheet } from '../../../base/browser/domStylesheets.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../platform/contextkey/common/contextkey.js'; import { Context } from '../../../platform/contextkey/browser/contextKeyService.js'; diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 9946f38f356..d835ce1dc9f 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -121,6 +121,7 @@ export const TITLE_BAR_SETTINGS = [ LayoutSettings.COMMAND_CENTER, LayoutSettings.EDITOR_ACTIONS_LOCATION, LayoutSettings.LAYOUT_ACTIONS, + 'workbench.navigationControl.enabled', 'window.menuBarVisibility', TitleBarSetting.TITLE_BAR_STYLE, TitleBarSetting.CUSTOM_TITLE_BAR_VISIBILITY, diff --git a/src/vs/workbench/browser/parts/editor/editorActions.ts b/src/vs/workbench/browser/parts/editor/editorActions.ts index 1b1a39b30ca..2deaa49417b 100644 --- a/src/vs/workbench/browser/parts/editor/editorActions.ts +++ b/src/vs/workbench/browser/parts/editor/editorActions.ts @@ -1425,7 +1425,7 @@ export class NavigateForwardAction extends Action2 { }, menu: [ { id: MenuId.MenubarGoMenu, group: '1_history_nav', order: 2 }, - { id: MenuId.CommandCenter, order: 2 } + { id: MenuId.CommandCenter, order: 2, when: ContextKeyExpr.has('config.workbench.navigationControl.enabled') } ] }); } @@ -1460,7 +1460,7 @@ export class NavigateBackwardsAction extends Action2 { }, menu: [ { id: MenuId.MenubarGoMenu, group: '1_history_nav', order: 1 }, - { id: MenuId.CommandCenter, order: 1 } + { id: MenuId.CommandCenter, order: 1, when: ContextKeyExpr.has('config.workbench.navigationControl.enabled') } ] }); } diff --git a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts index db8ede5ffa8..3c96d9f9ffc 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts @@ -15,61 +15,78 @@ import { CommandsRegistry } from '../../../../platform/commands/common/commands. import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { defaultKeybindingLabelStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { editorForeground, registerColor, transparent } from '../../../../platform/theme/common/colorRegistry.js'; - -registerColor('editorWatermark.foreground', { dark: transparent(editorForeground, 0.6), light: transparent(editorForeground, 0.68), hcDark: editorForeground, hcLight: editorForeground }, localize('editorLineHighlight', 'Foreground color for the labels in the editor watermark.')); +import { IStorageService, StorageScope, StorageTarget, WillSaveStateReason } from '../../../../platform/storage/common/storage.js'; +import { coalesce, shuffle } from '../../../../base/common/arrays.js'; interface WatermarkEntry { - readonly text: string; readonly id: string; - readonly mac?: boolean; - readonly when?: ContextKeyExpression; + readonly text: string; + readonly when?: { + native?: ContextKeyExpression; + web?: ContextKeyExpression; + }; } const showCommands: WatermarkEntry = { text: localize('watermark.showCommands', "Show All Commands"), id: 'workbench.action.showCommands' }; -const quickAccess: WatermarkEntry = { text: localize('watermark.quickAccess', "Go to File"), id: 'workbench.action.quickOpen' }; -const openFileNonMacOnly: WatermarkEntry = { text: localize('watermark.openFile', "Open File"), id: 'workbench.action.files.openFile', mac: false }; -const openFolderNonMacOnly: WatermarkEntry = { text: localize('watermark.openFolder', "Open Folder"), id: 'workbench.action.files.openFolder', mac: false }; -const openFileOrFolderMacOnly: WatermarkEntry = { text: localize('watermark.openFileFolder', "Open File or Folder"), id: 'workbench.action.files.openFileFolder', mac: true }; +const gotoFile: WatermarkEntry = { text: localize('watermark.quickAccess', "Go to File"), id: 'workbench.action.quickOpen' }; +const openFile: WatermarkEntry = { text: localize('watermark.openFile', "Open File"), id: 'workbench.action.files.openFile' }; +const openFolder: WatermarkEntry = { text: localize('watermark.openFolder', "Open Folder"), id: 'workbench.action.files.openFolder' }; +const openFileOrFolder: WatermarkEntry = { text: localize('watermark.openFileFolder', "Open File or Folder"), id: 'workbench.action.files.openFileFolder' }; const openRecent: WatermarkEntry = { text: localize('watermark.openRecent', "Open Recent"), id: 'workbench.action.openRecent' }; -const newUntitledFileMacOnly: WatermarkEntry = { text: localize('watermark.newUntitledFile', "New Untitled Text File"), id: 'workbench.action.files.newUntitledFile', mac: true }; +const newUntitledFile: WatermarkEntry = { text: localize('watermark.newUntitledFile', "New Untitled Text File"), id: 'workbench.action.files.newUntitledFile' }; const findInFiles: WatermarkEntry = { text: localize('watermark.findInFiles', "Find in Files"), id: 'workbench.action.findInFiles' }; -const toggleTerminal: WatermarkEntry = { text: localize({ key: 'watermark.toggleTerminal', comment: ['toggle is a verb here'] }, "Toggle Terminal"), id: 'workbench.action.terminal.toggleTerminal', when: ContextKeyExpr.equals('terminalProcessSupported', true) }; -const startDebugging: WatermarkEntry = { text: localize('watermark.startDebugging', "Start Debugging"), id: 'workbench.action.debug.start', when: ContextKeyExpr.equals('terminalProcessSupported', true) }; -const toggleFullscreen: WatermarkEntry = { text: localize({ key: 'watermark.toggleFullscreen', comment: ['toggle is a verb here'] }, "Toggle Full Screen"), id: 'workbench.action.toggleFullScreen' }; -const showSettings: WatermarkEntry = { text: localize('watermark.showSettings', "Show Settings"), id: 'workbench.action.openSettings' }; +const toggleTerminal: WatermarkEntry = { text: localize({ key: 'watermark.toggleTerminal', comment: ['toggle is a verb here'] }, "Toggle Terminal"), id: 'workbench.action.terminal.toggleTerminal', when: { web: ContextKeyExpr.equals('terminalProcessSupported', true) } }; +const startDebugging: WatermarkEntry = { text: localize('watermark.startDebugging', "Start Debugging"), id: 'workbench.action.debug.start', when: { web: ContextKeyExpr.equals('terminalProcessSupported', true) } }; +const openSettings: WatermarkEntry = { text: localize('watermark.openSettings', "Open Settings"), id: 'workbench.action.openSettings' }; +const openChat: WatermarkEntry = { text: localize('watermark.openChat', "Open Chat"), id: 'workbench.action.chat.open', when: { native: ContextKeyExpr.equals('chatPanelParticipantRegistered', true), web: ContextKeyExpr.equals('chatPanelParticipantRegistered', true) } }; +const openCopilotEdits: WatermarkEntry = { text: localize('watermark.openCopilotEdits', "Open Copilot Edits"), id: 'workbench.action.chat.openEditSession', when: { native: ContextKeyExpr.equals('chatEditingParticipantRegistered', true), web: ContextKeyExpr.equals('chatEditingParticipantRegistered', true) } }; -const noFolderEntries = [ +const emptyWindowEntries: WatermarkEntry[] = coalesce([ showCommands, - openFileNonMacOnly, - openFolderNonMacOnly, - openFileOrFolderMacOnly, + ...(isMacintosh && !isWeb ? [openFileOrFolder] : [openFile, openFolder]), openRecent, - newUntitledFileMacOnly + isMacintosh && !isWeb ? newUntitledFile : undefined, // fill in one more on macOS to get to 5 entries + openChat +]); + +const randomEmptyWindowEntries: WatermarkEntry[] = [ + /* Nothing yet */ ]; -const folderEntries = [ +const workspaceEntries: WatermarkEntry[] = [ showCommands, - quickAccess, + gotoFile, + openChat +]; + +const randomWorkspaceEntries: WatermarkEntry[] = [ findInFiles, startDebugging, toggleTerminal, - toggleFullscreen, - showSettings + openSettings, + openCopilotEdits ]; export class EditorGroupWatermark extends Disposable { + + private static readonly CACHED_WHEN = 'editorGroupWatermark.whenConditions'; + + private readonly cachedWhen: { [when: string]: boolean } = this.storageService.getObject(EditorGroupWatermark.CACHED_WHEN, StorageScope.PROFILE, Object.create(null)); + private readonly shortcuts: HTMLElement; private readonly transientDisposables = this._register(new DisposableStore()); - private enabled: boolean = false; - private workbenchState: WorkbenchState; - private keybindingLabels = new Set(); + private readonly keybindingLabels = this._register(new DisposableStore()); + + private enabled = false; + private workbenchState = this.contextService.getWorkbenchState(); constructor( container: HTMLElement, @IKeybindingService private readonly keybindingService: IKeybindingService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IConfigurationService private readonly configurationService: IConfigurationService + @IConfigurationService private readonly configurationService: IConfigurationService, + @IStorageService private readonly storageService: IStorageService ) { super(); @@ -83,75 +100,72 @@ export class EditorGroupWatermark extends Disposable { this.registerListeners(); - this.workbenchState = contextService.getWorkbenchState(); this.render(); } private registerListeners(): void { this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('workbench.tips.enabled')) { + if (e.affectsConfiguration('workbench.tips.enabled') && this.enabled !== this.configurationService.getValue('workbench.tips.enabled')) { this.render(); } })); this._register(this.contextService.onDidChangeWorkbenchState(workbenchState => { - if (this.workbenchState === workbenchState) { - return; + if (this.workbenchState !== workbenchState) { + this.workbenchState = workbenchState; + this.render(); } - - this.workbenchState = workbenchState; - this.render(); })); - const allEntriesWhenClauses = [...noFolderEntries, ...folderEntries].filter(entry => entry.when !== undefined).map(entry => entry.when!); - const allKeys = new Set(); - allEntriesWhenClauses.forEach(when => when.keys().forEach(key => allKeys.add(key))); - this._register(this.contextKeyService.onDidChangeContext(e => { - if (e.affectsSome(allKeys)) { - this.render(); + this._register(this.storageService.onWillSaveState(e => { + if (e.reason === WillSaveStateReason.SHUTDOWN) { + const entries = [...emptyWindowEntries, ...randomEmptyWindowEntries, ...workspaceEntries, ...randomWorkspaceEntries]; + for (const entry of entries) { + const when = isWeb ? entry.when?.web : entry.when?.native; + if (when) { + this.cachedWhen[entry.id] = this.contextKeyService.contextMatchesRules(when); + } + } + + this.storageService.store(EditorGroupWatermark.CACHED_WHEN, JSON.stringify(this.cachedWhen), StorageScope.PROFILE, StorageTarget.MACHINE); } })); } private render(): void { - const enabled = this.configurationService.getValue('workbench.tips.enabled'); + this.enabled = this.configurationService.getValue('workbench.tips.enabled'); - if (enabled === this.enabled) { + clearNode(this.shortcuts); + this.transientDisposables.clear(); + + if (!this.enabled) { return; } - this.enabled = enabled; - this.clear(); - - if (!enabled) { - return; - } + const fixedEntries = this.filterEntries(this.workbenchState !== WorkbenchState.EMPTY ? workspaceEntries : emptyWindowEntries, false /* not shuffled */); + const randomEntries = this.filterEntries(this.workbenchState !== WorkbenchState.EMPTY ? randomWorkspaceEntries : randomEmptyWindowEntries, true /* shuffled */).slice(0, Math.max(0, 5 - fixedEntries.length)); + const entries = [...fixedEntries, ...randomEntries]; const box = append(this.shortcuts, $('.watermark-box')); - const folder = this.workbenchState !== WorkbenchState.EMPTY; - const selected = (folder ? folderEntries : noFolderEntries) - .filter(entry => !('when' in entry) || this.contextKeyService.contextMatchesRules(entry.when)) - .filter(entry => !('mac' in entry) || entry.mac === (isMacintosh && !isWeb)) - .filter(entry => !!CommandsRegistry.getCommand(entry.id)) - .filter(entry => !!this.keybindingService.lookupKeybinding(entry.id)); const update = () => { clearNode(box); - this.keybindingLabels.forEach(label => label.dispose()); this.keybindingLabels.clear(); - for (const entry of selected) { + for (const entry of entries) { const keys = this.keybindingService.lookupKeybinding(entry.id); if (!keys) { continue; } + const dl = append(box, $('dl')); const dt = append(dl, $('dt')); dt.textContent = entry.text; + const dd = append(dl, $('dd')); - const label = new KeybindingLabel(dd, OS, { renderUnboundKeybindings: true, ...defaultKeybindingLabelStyles }); + + const label = this.keybindingLabels.add(new KeybindingLabel(dd, OS, { renderUnboundKeybindings: true, ...defaultKeybindingLabelStyles })); label.set(keys); - this.keybindingLabels.add(label); } }; @@ -159,14 +173,18 @@ export class EditorGroupWatermark extends Disposable { this.transientDisposables.add(this.keybindingService.onDidUpdateKeybindings(update)); } - private clear(): void { - clearNode(this.shortcuts); - this.transientDisposables.clear(); - } + private filterEntries(entries: WatermarkEntry[], shuffleEntries: boolean): WatermarkEntry[] { + const filteredEntries = entries + .filter(entry => (isWeb && !entry.when?.web) || (!isWeb && !entry.when?.native) || this.cachedWhen[entry.id]) + .filter(entry => !!CommandsRegistry.getCommand(entry.id)) + .filter(entry => !!this.keybindingService.lookupKeybinding(entry.id)); - override dispose(): void { - super.dispose(); - this.clear(); - this.keybindingLabels.forEach(label => label.dispose()); + if (shuffleEntries) { + shuffle(filteredEntries); + } + + return filteredEntries; } } + +registerColor('editorWatermark.foreground', { dark: transparent(editorForeground, 0.6), light: transparent(editorForeground, 0.68), hcDark: editorForeground, hcLight: editorForeground }, localize('editorLineHighlight', 'Foreground color for the labels in the editor watermark.')); diff --git a/src/vs/workbench/browser/parts/paneCompositeBar.ts b/src/vs/workbench/browser/parts/paneCompositeBar.ts index 98dc40ad654..db3d98034a0 100644 --- a/src/vs/workbench/browser/parts/paneCompositeBar.ts +++ b/src/vs/workbench/browser/parts/paneCompositeBar.ts @@ -11,7 +11,8 @@ import { IInstantiationService } from '../../../platform/instantiation/common/in import { IDisposable, DisposableStore, Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { IColorTheme } from '../../../platform/theme/common/themeService.js'; import { CompositeBar, ICompositeBarItem, CompositeDragAndDrop } from './compositeBar.js'; -import { Dimension, createCSSRule, isMouseEvent } from '../../../base/browser/dom.js'; +import { Dimension, isMouseEvent } from '../../../base/browser/dom.js'; +import { createCSSRule } from '../../../base/browser/domStylesheets.js'; import { asCSSUrl } from '../../../base/browser/cssValue.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts index a1182f5bd0e..fddbd06e307 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts @@ -16,7 +16,8 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { STATUS_BAR_BACKGROUND, STATUS_BAR_FOREGROUND, STATUS_BAR_NO_FOLDER_BACKGROUND, STATUS_BAR_ITEM_HOVER_BACKGROUND, STATUS_BAR_BORDER, STATUS_BAR_NO_FOLDER_FOREGROUND, STATUS_BAR_NO_FOLDER_BORDER, STATUS_BAR_ITEM_COMPACT_HOVER_BACKGROUND, STATUS_BAR_ITEM_FOCUS_BORDER, STATUS_BAR_FOCUS_BORDER } from '../../../common/theme.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { contrastBorder, activeContrastBorder } from '../../../../platform/theme/common/colorRegistry.js'; -import { EventHelper, createStyleSheet, addDisposableListener, EventType, clearNode, getWindow } from '../../../../base/browser/dom.js'; +import { EventHelper, addDisposableListener, EventType, clearNode, getWindow } from '../../../../base/browser/dom.js'; +import { createStyleSheet } from '../../../../base/browser/domStylesheets.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { Parts, IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; diff --git a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css index a0d84cfca56..6aa44168c7b 100644 --- a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css +++ b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css @@ -392,7 +392,7 @@ /* Action Tool Bar Controls */ .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-right > .action-toolbar-container { display: none; - padding-right: 2px; + padding-right: 4px; flex-grow: 0; flex-shrink: 0; text-align: center; diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts index ec26331d37e..54ba1575b78 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts @@ -57,9 +57,15 @@ registerAction2(class ToggleCommandCenter extends ToggleTitleBarConfigAction { } }); +registerAction2(class ToggleNavigationControl extends ToggleTitleBarConfigAction { + constructor() { + super('workbench.navigationControl.enabled', localize('toggle.navigation', 'Navigation Controls'), localize('toggle.navigationDescription', "Toggle visibility of the Navigation Controls in title bar"), 2, false, ContextKeyExpr.has('config.window.commandCenter')); + } +}); + registerAction2(class ToggleLayoutControl extends ToggleTitleBarConfigAction { constructor() { - super('workbench.layoutControl.enabled', localize('toggle.layout', 'Layout Controls'), localize('toggle.layoutDescription', "Toggle visibility of the Layout Controls in title bar"), 2, true); + super(LayoutSettings.LAYOUT_ACTIONS, localize('toggle.layout', 'Layout Controls'), localize('toggle.layoutDescription', "Toggle visibility of the Layout Controls in title bar"), 3, true); } }); diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 3b8b1d7b54e..717c0a0e955 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -644,6 +644,14 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } } + // --- Activity Actions + if (this.activityActionsEnabled) { + if (isAccountsActionVisible(this.storageService)) { + actions.primary.push(ACCOUNTS_ACTIVITY_TILE_ACTION); + } + actions.primary.push(GLOBAL_ACTIVITY_TITLE_ACTION); + } + // --- Layout Actions if (this.layoutToolbarMenu) { fillInActionBarActions( @@ -653,14 +661,6 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { ); } - // --- Activity Actions - if (this.activityActionsEnabled) { - if (isAccountsActionVisible(this.storageService)) { - actions.primary.push(ACCOUNTS_ACTIVITY_TILE_ACTION); - } - actions.primary.push(GLOBAL_ACTIVITY_TITLE_ACTION); - } - this.actionToolBar.setActions(prepareActions(actions.primary), prepareActions(actions.secondary)); }; diff --git a/src/vs/workbench/browser/parts/views/viewFilter.ts b/src/vs/workbench/browser/parts/views/viewFilter.ts index 2dfd599de65..9eae6e9bc5f 100644 --- a/src/vs/workbench/browser/parts/views/viewFilter.ts +++ b/src/vs/workbench/browser/parts/views/viewFilter.ts @@ -165,10 +165,11 @@ export class FilterWidget extends Widget { } private createInput(container: HTMLElement): [ContextScopedHistoryInputBox, DOM.IFocusTracker] { + const history = this.options.history || []; const inputBox = this._register(this.instantiationService.createInstance(ContextScopedHistoryInputBox, container, this.contextViewService, { placeholder: this.options.placeholder, ariaLabel: this.options.ariaLabel, - history: this.options.history || [], + history: new Set(history), showHistoryHint: () => showHistoryKeybindingHint(this.keybindingService), inputBoxStyles: defaultInputBoxStyles })); diff --git a/src/vs/workbench/browser/parts/views/viewPane.ts b/src/vs/workbench/browser/parts/views/viewPane.ts index 54e8ca83530..0e3e70d07ee 100644 --- a/src/vs/workbench/browser/parts/views/viewPane.ts +++ b/src/vs/workbench/browser/parts/views/viewPane.ts @@ -7,7 +7,8 @@ import './media/paneviewlet.css'; import * as nls from '../../../../nls.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { asCssVariable, foreground } from '../../../../platform/theme/common/colorRegistry.js'; -import { after, append, $, trackFocus, EventType, addDisposableListener, createCSSRule, Dimension, reset, isAncestorOfActiveElement, isActiveElement } from '../../../../base/browser/dom.js'; +import { after, append, $, trackFocus, EventType, addDisposableListener, Dimension, reset, isAncestorOfActiveElement, isActiveElement } from '../../../../base/browser/dom.js'; +import { createCSSRule } from '../../../../base/browser/domStylesheets.js'; import { asCssValueWithDefault, asCSSUrl } from '../../../../base/browser/cssValue.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { Action, IAction, IActionRunner } from '../../../../base/common/actions.js'; diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 4d2143e0ecf..bb783fb1afb 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -610,6 +610,13 @@ const registry = Registry.as(ConfigurationExtensions.Con tags: ['accessibility'], enum: ['on', 'off', 'auto'] }, + 'workbench.navigationControl.enabled': { + 'type': 'boolean', + 'default': true, + 'markdownDescription': isWeb ? + localize('navigationControlEnabledWeb', "Controls whether the navigation control in the title bar is shown.") : + localize({ key: 'navigationControlEnabled', comment: ['{0}, {1} is a placeholder for a setting identifier.'] }, "Controls whether the navigation control is shown in the custom title bar. This setting only has an effect when {0} is not set to {1}.", '`#window.customTitleBarVisibility#`', '`never`') + }, [LayoutSettings.LAYOUT_ACTIONS]: { 'type': 'boolean', 'default': true, @@ -625,7 +632,7 @@ const registry = Registry.as(ConfigurationExtensions.Con localize('layoutcontrol.type.toggles', "Shows several buttons for toggling the visibility of the panels and side bar."), localize('layoutcontrol.type.both', "Shows both the dropdown and toggle buttons."), ], - 'default': 'both', + 'default': 'toggles', 'description': localize('layoutControlType', "Controls whether the layout control in the custom title bar is displayed as a single menu button or with multiple UI toggles."), }, 'workbench.tips.enabled': { diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 470ecc8c091..9e64c7226d7 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -708,6 +708,12 @@ const configuration: IConfigurationNode = { 'type': 'boolean', 'description': localize('accessibility.replEditor.readLastExecutedOutput', "Controls whether the output from an execution in the native REPL will be announced."), 'default': true, + }, + 'accessibility.replEditor.autoFocusReplExecution': { + type: 'string', + enum: ['none', 'input', 'lastExecution'], + default: 'input', + description: localize('replEditor.autoFocusAppendedCell', "Control whether focus should automatically be sent to the REPL when code is executed."), } } }; diff --git a/src/vs/workbench/contrib/accessibility/browser/unfocusedViewDimmingContribution.ts b/src/vs/workbench/contrib/accessibility/browser/unfocusedViewDimmingContribution.ts index 136a9282778..4a5dbf71a86 100644 --- a/src/vs/workbench/contrib/accessibility/browser/unfocusedViewDimmingContribution.ts +++ b/src/vs/workbench/contrib/accessibility/browser/unfocusedViewDimmingContribution.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createStyleSheet } from '../../../../base/browser/dom.js'; +import { createStyleSheet } from '../../../../base/browser/domStylesheets.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { clamp } from '../../../../base/common/numbers.js'; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 6b1cec5efdd..1417c256853 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -35,7 +35,7 @@ import { extractAgentAndCommand } from '../../common/chatParserTypes.js'; import { IChatDetail, IChatService } from '../../common/chatService.js'; import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/chatViewModel.js'; import { IChatWidgetHistoryService } from '../../common/chatWidgetHistoryService.js'; -import { CHAT_VIEW_ID, IChatWidget, IChatWidgetService, showChatView } from '../chat.js'; +import { ChatViewId, IChatWidget, IChatWidgetService, showChatView } from '../chat.js'; import { IChatEditorOptions } from '../chatEditor.js'; import { ChatEditorInput } from '../chatEditorInput.js'; import { ChatViewPane } from '../chatViewPane.js'; @@ -99,7 +99,7 @@ class OpenChatGlobalAction extends Action2 { }, menu: { id: MenuId.ChatCommandCenter, - group: 'a_chat', + group: 'a_open', order: 1 } }); @@ -162,7 +162,7 @@ class ChatHistoryAction extends Action2 { title: localize2('chat.history.label', "Show Chats..."), menu: { id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', CHAT_VIEW_ID), + when: ContextKeyExpr.equals('view', ChatViewId), group: 'navigation', order: 2 }, @@ -253,7 +253,7 @@ class ChatHistoryAction extends Action2 { try { const item = picker.selectedItems[0]; const sessionId = item.chat.sessionId; - const view = await viewsService.openView(CHAT_VIEW_ID) as ChatViewPane; + const view = await viewsService.openView(ChatViewId) as ChatViewPane; view.loadSession(sessionId); } finally { picker.hide(); @@ -368,7 +368,7 @@ export function registerChatActions() { const chatService = accessor.get(IChatService); chatService.clearAllHistoryEntries(); - const chatView = viewsService.getViewWithId(CHAT_VIEW_ID) as ChatViewPane | undefined; + const chatView = viewsService.getViewWithId(ChatViewId) as ChatViewPane | undefined; if (chatView) { chatView.widget.clear(); } @@ -459,17 +459,11 @@ export function registerChatActions() { id: LearnMoreChatAction.ID, title: LearnMoreChatAction.TITLE, category: CHAT_CATEGORY, - menu: [{ + menu: { id: MenuId.ChatCommandCenter, - group: 'a_atfirst', - order: 2, - when: ChatContextKeys.panelParticipantRegistered.negate() - }, { - id: MenuId.ChatCommandCenter, - group: 'z_atlast', - order: 1, - when: ChatContextKeys.panelParticipantRegistered - }] + group: 'z_learn', + order: 1 + } }); } @@ -519,7 +513,7 @@ registerAction2(class ToggleChatControl extends ToggleTitleBarConfigAction { super( 'chat.commandCenter.enabled', localize('toggle.chatControl', 'Chat Controls'), - localize('toggle.chatControlsDescription', "Toggle visibility of the Chat Controls in title bar"), 3, false, + localize('toggle.chatControlsDescription', "Toggle visibility of the Chat Controls in title bar"), 4, false, ContextKeyExpr.and( ContextKeyExpr.has('config.window.commandCenter'), ContextKeyExpr.or( @@ -536,15 +530,12 @@ export class ChatCommandCenterRendering implements IWorkbenchContribution { static readonly ID = 'chat.commandCenterRendering'; - private readonly _store = new DisposableStore(); - private _dropdown: DropdownWithPrimaryActionViewItem | undefined; - constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @IChatAgentService agentService: IChatAgentService, @IInstantiationService instantiationService: IInstantiationService, ) { - this._store.add(actionViewItemService.register(MenuId.CommandCenter, MenuId.ChatCommandCenter, (action, options) => { + actionViewItemService.register(MenuId.CommandCenter, MenuId.ChatCommandCenter, (action, options) => { if (!(action instanceof SubmenuItemAction)) { return undefined; } @@ -563,14 +554,7 @@ export class ChatCommandCenterRendering implements IWorkbenchContribution { icon: defaultChat.icon, }, undefined, undefined, undefined, undefined); - this._dropdown = instantiationService.createInstance(DropdownWithPrimaryActionViewItem, primaryAction, dropdownAction, action.actions, '', { ...options, skipTelemetry: true }); - - return this._dropdown; - }, agentService.onDidChangeAgents)); - } - - dispose() { - this._store.dispose(); + return instantiationService.createInstance(DropdownWithPrimaryActionViewItem, primaryAction, dropdownAction, action.actions, '', { ...options, skipTelemetry: true }); + }, agentService.onDidChangeAgents); } } - diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts index f7a9e4016c3..b84f3df93e0 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts @@ -18,7 +18,7 @@ import { isChatViewTitleActionContext } from '../../common/chatActions.js'; import { ChatAgentLocation } from '../../common/chatAgents.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, WorkingSetEntryState } from '../../common/chatEditingService.js'; -import { CHAT_VIEW_ID, EDITS_VIEW_ID, IChatWidgetService } from '../chat.js'; +import { ChatViewId, EditsViewId, IChatWidgetService } from '../chat.js'; import { ChatEditorInput } from '../chatEditorInput.js'; import { ChatViewPane } from '../chatViewPane.js'; import { CHAT_CATEGORY } from './chatActions.js'; @@ -74,7 +74,7 @@ export function registerNewChatActions() { }, { id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', CHAT_VIEW_ID), + when: ContextKeyExpr.equals('view', ChatViewId), group: 'navigation', order: -1 }] @@ -116,7 +116,7 @@ export function registerNewChatActions() { }, { id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', EDITS_VIEW_ID), + when: ContextKeyExpr.equals('view', EditsViewId), group: 'navigation', order: -1 }, @@ -188,7 +188,7 @@ export function registerNewChatActions() { } } else { // Is running from f1 or keybinding - const chatView = await viewsService.openView(EDITS_VIEW_ID) as ChatViewPane; + const chatView = await viewsService.openView(EditsViewId) as ChatViewPane; const widget = chatView.widget; announceChatCleared(accessibilitySignalService); @@ -234,7 +234,7 @@ export function registerNewChatActions() { // Is running from f1 or keybinding const viewsService = accessor.get(IViewsService); - const chatView = await viewsService.openView(EDITS_VIEW_ID) as ChatViewPane; + const chatView = await viewsService.openView(EditsViewId) as ChatViewPane; const widget = chatView.widget; announceChatCleared(accessibilitySignalService); @@ -256,7 +256,7 @@ export function registerNewChatActions() { f1: true, menu: [{ id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', EDITS_VIEW_ID), + when: ContextKeyExpr.equals('view', EditsViewId), group: 'navigation', order: -3 }] @@ -288,7 +288,7 @@ export function registerNewChatActions() { f1: true, menu: [{ id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', EDITS_VIEW_ID), + when: ContextKeyExpr.equals('view', EditsViewId), group: 'navigation', order: -2 }] @@ -320,8 +320,8 @@ export function registerNewChatActions() { f1: true, menu: [{ id: MenuId.ViewTitle, - when: ContextKeyExpr.and(ContextKeyExpr.equals('view', CHAT_VIEW_ID), ChatContextKeys.editingParticipantRegistered, - ContextKeyExpr.equals(`view.${EDITS_VIEW_ID}.visible`, false), + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', ChatViewId), ChatContextKeys.editingParticipantRegistered, + ContextKeyExpr.equals(`view.${EditsViewId}.visible`, false), ContextKeyExpr.or( ContextKeyExpr.and(ContextKeyExpr.equals(`workbench.panel.chat.defaultViewContainerLocation`, true), ContextKeyExpr.equals(`workbench.panel.chatEditing.defaultViewContainerLocation`, false)), ContextKeyExpr.and(ContextKeyExpr.equals(`workbench.panel.chat.defaultViewContainerLocation`, false), ContextKeyExpr.equals(`workbench.panel.chatEditing.defaultViewContainerLocation`, true)), @@ -331,12 +331,12 @@ export function registerNewChatActions() { }, { id: MenuId.ChatCommandCenter, when: ChatContextKeys.editingParticipantRegistered, - group: 'a_chatEdit', - order: 1 + group: 'a_open', + order: 2 }, { id: MenuId.ChatEditingEditorContent, group: 'navigate', - order: 1, + order: 4, }], keybinding: { weight: KeybindingWeight.WorkbenchContrib, @@ -344,14 +344,14 @@ export function registerNewChatActions() { linux: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.KeyI }, - when: ContextKeyExpr.and(ContextKeyExpr.notEquals('view', EDITS_VIEW_ID), ChatContextKeys.editingParticipantRegistered) + when: ContextKeyExpr.and(ContextKeyExpr.notEquals('view', EditsViewId), ChatContextKeys.editingParticipantRegistered) } }); } async run(accessor: ServicesAccessor, ...args: any[]) { const viewsService = accessor.get(IViewsService); - const chatView = await viewsService.openView(EDITS_VIEW_ID) as ChatViewPane; + const chatView = await viewsService.openView(EditsViewId) as ChatViewPane; chatView.widget.focusInput(); } }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts index dfe00611984..7244011a432 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts @@ -191,15 +191,22 @@ export function registerChatCodeBlockActions() { category: CHAT_CATEGORY, icon: Codicon.gitPullRequestGoToChanges, - menu: { - id: MenuId.ChatCodeBlock, - group: 'navigation', - when: ContextKeyExpr.and( - ChatContextKeys.inChatSession, - ...shellLangIds.map(e => ContextKeyExpr.notEquals(EditorContextKeys.languageId.key, e)) - ), - order: 10 - }, + menu: [ + { + id: MenuId.ChatCodeBlock, + group: 'navigation', + when: ContextKeyExpr.and( + ...shellLangIds.map(e => ContextKeyExpr.notEquals(EditorContextKeys.languageId.key, e)) + ), + order: 10 + }, + { + id: MenuId.ChatCodeBlock, + when: ContextKeyExpr.or( + ...shellLangIds.map(e => ContextKeyExpr.equals(EditorContextKeys.languageId.key, e)) + ) + }, + ], keybinding: { when: ContextKeyExpr.or(ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeys.inChatInput.negate()), accessibleViewInCodeBlock), primary: KeyMod.CtrlCmd | KeyCode.Enter, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts index ffdd147cf83..be164a6ec03 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts @@ -8,6 +8,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { Schemas } from '../../../../../base/common/network.js'; import { isElectron } from '../../../../../base/common/platform.js'; +import { dirname } from '../../../../../base/common/resources.js'; import { compare } from '../../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -63,7 +64,7 @@ export function registerChatContextActions() { /** * We fill the quickpick with these types, and enable some quick access providers */ -type IAttachmentQuickPickItem = ICommandVariableQuickPickItem | IQuickAccessQuickPickItem | IToolQuickPickItem | IImageQuickPickItem | IVariableQuickPickItem | IOpenEditorsQuickPickItem | ISearchResultsQuickPickItem | IScreenShotQuickPickItem; +type IAttachmentQuickPickItem = ICommandVariableQuickPickItem | IQuickAccessQuickPickItem | IToolQuickPickItem | IImageQuickPickItem | IVariableQuickPickItem | IOpenEditorsQuickPickItem | ISearchResultsQuickPickItem | IScreenShotQuickPickItem | IRelatedFilesQuickPickItem; /** * These are the types that we can get out of the quick pick @@ -110,6 +111,19 @@ function isScreenshotQuickPickItem(obj: unknown): obj is IScreenShotQuickPickIte && (obj as IScreenShotQuickPickItem).kind === 'screenshot'); } +function isRelatedFileQuickPickItem(obj: unknown): obj is IRelatedFilesQuickPickItem { + return ( + typeof obj === 'object' + && (obj as IRelatedFilesQuickPickItem).kind === 'related-files' + ); +} + +interface IRelatedFilesQuickPickItem extends IQuickPickItem { + kind: 'related-files'; + id: string; + label: string; +} + interface IImageQuickPickItem extends IQuickPickItem { kind: 'image'; id: string; @@ -202,7 +216,7 @@ class AttachFileToChatAction extends AttachFileAction { precondition: ChatContextKeys.enabled, menu: [{ id: MenuId.ChatCommandCenter, - group: 'a_chat', + group: 'b_chat_context', when: ActiveEditorContext.isEqualTo('workbench.editors.files.textFileEditor'), order: 10, }, { @@ -239,8 +253,8 @@ class AttachSelectionToChatAction extends Action2 { precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ActiveEditorContext.isEqualTo('workbench.editors.files.textFileEditor')), menu: { id: MenuId.ChatCommandCenter, - group: 'a_chat', - order: 11, + group: 'b_chat_context', + order: 15, } }); } @@ -274,9 +288,9 @@ class AttachFileToEditingSessionAction extends AttachFileAction { precondition: ChatContextKeys.enabled, menu: [{ id: MenuId.ChatCommandCenter, - group: 'a_chat', + group: 'c_edits_context', when: ActiveEditorContext.isEqualTo('workbench.editors.files.textFileEditor'), - order: 11, + order: 10, }, { id: MenuId.SearchContext, group: 'z_chat', @@ -311,8 +325,8 @@ class AttachSelectionToEditingSessionAction extends Action2 { precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ActiveEditorContext.isEqualTo('workbench.editors.files.textFileEditor')), menu: { id: MenuId.ChatCommandCenter, - group: 'a_chat', - order: 12, + group: 'c_edits_context', + order: 15, } }); } @@ -384,12 +398,13 @@ export class AttachContextAction extends Action2 { `:${item.range.startLineNumber}`); } - private async _attachContext(widget: IChatWidget, commandService: ICommandService, clipboardService: IClipboardService, editorService: IEditorService, labelService: ILabelService, viewsService: IViewsService, chatEditingService: IChatEditingService | undefined, hostService: IHostService, isInBackground?: boolean, ...picks: IChatContextQuickPickItem[]) { + private async _attachContext(widget: IChatWidget, quickInputService: IQuickInputService, commandService: ICommandService, clipboardService: IClipboardService, editorService: IEditorService, labelService: ILabelService, viewsService: IViewsService, chatEditingService: IChatEditingService | undefined, hostService: IHostService, isInBackground?: boolean, ...picks: IChatContextQuickPickItem[]) { const toAttach: IChatRequestVariableEntry[] = []; for (const pick of picks) { if (isISymbolQuickPickItem(pick) && pick.symbol) { // Workspace symbol toAttach.push({ + kind: 'symbol', id: this._getFileContextId(pick.symbol.location), value: pick.symbol.location, fullName: pick.label, @@ -462,6 +477,37 @@ export class AttachContextAction extends Action2 { }); } } + } else if (isRelatedFileQuickPickItem(pick)) { + // Get all provider results and show them in a second tier picker + const chatSessionId = widget.viewModel?.sessionId; + if (!chatSessionId || !chatEditingService) { + continue; + } + const relatedFiles = await chatEditingService.getRelatedFiles(chatSessionId, widget.getInput(), CancellationToken.None); + if (!relatedFiles) { + continue; + } + const attachments = widget.attachmentModel.getAttachmentIDs(); + const itemsPromise = chatEditingService.getRelatedFiles(chatSessionId, widget.getInput(), CancellationToken.None) + .then((files) => (files ?? []).reduce<((IQuickPickItem & { value: URI }) | IQuickPickSeparator)[]>((acc, cur) => { + acc.push({ type: 'separator', label: cur.group }); + const workingSet = chatEditingService.currentEditingSessionObs.get()?.workingSet; + for (const file of cur.files) { + acc.push({ + type: 'item', + label: labelService.getUriBasenameLabel(file.uri), + description: labelService.getUriLabel(dirname(file.uri), { relative: true }), + value: file.uri, + disabled: workingSet?.has(file.uri) || attachments.has(this._getFileContextId({ resource: file.uri })), + picked: true + }); + } + return acc; + }, [])); + const selectedFiles = await quickInputService.pick(itemsPromise, { placeHolder: localize('relatedFiles', 'Add related files to your working set'), canPickMany: true }); + for (const file of selectedFiles ?? []) { + chatEditingService?.currentEditingSessionObs.get()?.addFileToWorkingSet(file.value); + } } else if (isScreenshotQuickPickItem(pick)) { const blob = await hostService.getScreenshot(); if (blob) { @@ -654,6 +700,14 @@ export class AttachContextAction extends Action2 { }); } } else if (context.showFilesOnly) { + if (chatEditingService?.hasRelatedFilesProviders() && (widget.getInput() || chatEditingService.currentEditingSessionObs.get()?.workingSet.size)) { + quickPickItems.push({ + kind: 'related-files', + id: 'related-files', + label: localize('chatContext.relatedFiles', 'Related Files'), + iconClass: ThemeIcon.asClassName(Codicon.sparkle), + }); + } if (editorService.editors.filter(e => e instanceof FileEditorInput || e instanceof DiffEditorInput || e instanceof UntitledTextEditorInput).length > 0) { quickPickItems.push({ kind: 'open-editors', @@ -698,7 +752,7 @@ export class AttachContextAction extends Action2 { if (!clipboardService) { return; } - this._attachContext(widget, commandService, clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, isBackgroundAccept, item); + this._attachContext(widget, quickInputService, commandService, clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, isBackgroundAccept, item); if (isQuickChat(widget)) { quickChatService.open(); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 3279a6a0b97..2e2159439ef 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -18,7 +18,7 @@ import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { applyingChatEditsContextKey, IChatEditingService } from '../../common/chatEditingService.js'; import { chatAgentLeader, extractAgentAndCommand } from '../../common/chatParserTypes.js'; import { IChatService } from '../../common/chatService.js'; -import { EDITS_VIEW_ID, IChatWidget, IChatWidgetService } from '../chat.js'; +import { EditsViewId, IChatWidget, IChatWidgetService } from '../chat.js'; import { ChatViewPane } from '../chatViewPane.js'; import { CHAT_CATEGORY } from './chatActions.js'; @@ -282,7 +282,7 @@ class SendToChatEditingAction extends Action2 { await currentEditingSession?.stop(); } - const { widget: editingWidget } = await viewsService.openView(EDITS_VIEW_ID) as ChatViewPane; + const { widget: editingWidget } = await viewsService.openView(EditsViewId) as ChatViewPane; for (const attachment of widget.attachmentModel.attachments) { if (attachment.isFile && URI.isUri(attachment.value)) { chatEditingService.currentEditingSessionObs.get()?.addFileToWorkingSet(attachment.value); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts index e2a295bb6e3..e927be89e77 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts @@ -9,7 +9,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { CHAT_CATEGORY } from './chatActions.js'; -import { CHAT_VIEW_ID, IChatWidgetService } from '../chat.js'; +import { ChatViewId, IChatWidgetService } from '../chat.js'; import { ChatEditor, IChatEditorOptions } from '../chatEditor.js'; import { ChatEditorInput } from '../chatEditorInput.js'; import { ChatViewPane } from '../chatViewPane.js'; @@ -35,8 +35,9 @@ export function registerMoveActions() { f1: true, menu: { id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', CHAT_VIEW_ID), - order: 0 + when: ContextKeyExpr.equals('view', ChatViewId), + order: 0, + group: '1_open' }, }); } @@ -57,8 +58,9 @@ export function registerMoveActions() { f1: true, menu: { id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', CHAT_VIEW_ID), - order: 0 + when: ContextKeyExpr.equals('view', ChatViewId), + order: 0, + group: '1_open' }, }); } @@ -125,10 +127,10 @@ async function moveToSidebar(accessor: ServicesAccessor): Promise { let view: ChatViewPane; if (chatEditor instanceof ChatEditor && chatEditorInput instanceof ChatEditorInput && chatEditorInput.sessionId) { await editorService.closeEditor({ editor: chatEditor.input, groupId: editorGroupService.activeGroup.id }); - view = await viewsService.openView(CHAT_VIEW_ID) as ChatViewPane; + view = await viewsService.openView(ChatViewId) as ChatViewPane; view.loadSession(chatEditorInput.sessionId, chatEditor.getViewState()); } else { - view = await viewsService.openView(CHAT_VIEW_ID) as ChatViewPane; + view = await viewsService.openView(ChatViewId) as ChatViewPane; } view.focus(); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts index 05c9bf646c4..3c75e0c503c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts @@ -113,7 +113,7 @@ class QuickChatGlobalAction extends Action2 { }, menu: { id: MenuId.ChatCommandCenter, - group: 'c_quickChat', + group: 'e_quickChat', order: 5 }, metadata: { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index b9a5e697fad..078be1cba91 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { marked } from '../../../../../base/common/marked/marked.js'; -import { Schemas } from '../../../../../base/common/network.js'; import { basename } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; @@ -19,6 +19,8 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ResourceNotebookCellEdit } from '../../../bulkEdit/browser/bulkCellEdits.js'; @@ -28,12 +30,11 @@ import { CellEditType, CellKind, NOTEBOOK_EDITOR_ID } from '../../../notebook/co import { NOTEBOOK_IS_ACTIVE_EDITOR } from '../../../notebook/common/notebookContextKeys.js'; import { ChatAgentLocation, IChatAgentService } from '../../common/chatAgents.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; -import { applyingChatEditsFailedContextKey, IChatEditingService, isChatEditingActionContext, WorkingSetEntryState } from '../../common/chatEditingService.js'; -import { IParsedChatRequest } from '../../common/chatParserTypes.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatProgress, IChatService } from '../../common/chatService.js'; +import { applyingChatEditsFailedContextKey, ChatEditingSessionState, IChatEditingService, isChatEditingActionContext } from '../../common/chatEditingService.js'; +import { IChatRequestModel } from '../../common/chatModel.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatService } from '../../common/chatService.js'; import { isRequestVM, isResponseVM } from '../../common/chatViewModel.js'; -import { ChatTreeItem, EDITS_VIEW_ID, IChatWidgetService } from '../chat.js'; -import { ChatViewPane } from '../chatViewPane.js'; +import { ChatTreeItem, EditsViewId, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY } from './chatActions.js'; export const MarkUnhelpfulActionId = 'workbench.action.chat.markUnhelpful'; @@ -401,150 +402,123 @@ export function registerChatTitleActions() { f1: false, category: CHAT_CATEGORY, icon: Codicon.goToEditingSession, - precondition: ContextKeyExpr.and(ChatContextKeys.editingParticipantRegistered, ChatContextKeys.location.notEqualsTo(ChatAgentLocation.EditingSession)), + precondition: ContextKeyExpr.and(ChatContextKeys.editingParticipantRegistered, ChatContextKeys.requestInProgress.toNegated(), ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel)), menu: { id: MenuId.ChatMessageFooter, group: 'navigation', order: 4, - when: ContextKeyExpr.false() - // when: ContextKeyExpr.and(CONTEXT_CHAT_ENABLED, CONTEXT_CHAT_EDITING_PARTICIPANT_REGISTERED, CONTEXT_CHAT_LOCATION.notEqualsTo(ChatAgentLocation.EditingSession)) + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.isResponse, ChatContextKeys.editingParticipantRegistered, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel)) } }); } async run(accessor: ServicesAccessor, ...args: any[]) { - if (!accessor.get(IChatAgentService).getDefaultAgent(ChatAgentLocation.EditingSession)) { - return; - } + const logService = accessor.get(ILogService); const chatWidgetService = accessor.get(IChatWidgetService); const chatService = accessor.get(IChatService); + const chatAgentService = accessor.get(IChatAgentService); const viewsService = accessor.get(IViewsService); - const dialogService = accessor.get(IDialogService); + // const dialogService = accessor.get(IDialogService); const chatEditingService = accessor.get(IChatEditingService); + const quickPickService = accessor.get(IQuickInputService); - let item: ChatTreeItem | undefined = args[0]; - if (!isResponseVM(item)) { - const widget = chatWidgetService.lastFocusedWidget; - item = widget?.getFocus(); - } - - if (!item) { + const editAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.EditingSession); + if (!editAgent) { + logService.trace('[CHAT_MOVE] No edit agent found'); return; } - const chatModel = chatService.getSession(item.sessionId); - if (chatModel?.initialLocation === ChatAgentLocation.EditingSession) { + const sourceWidget = chatWidgetService.lastFocusedWidget; + if (!sourceWidget || !sourceWidget.viewModel) { + logService.trace('[CHAT_MOVE] NO source model'); return; } - const requestId = isRequestVM(item) ? item.id : - isResponseVM(item) ? item.requestId : undefined; - const request = chatModel?.getRequests().find(candidate => candidate.id === requestId); + const sourceModel = sourceWidget.viewModel.model; + let sourceRequests = sourceModel.getRequests().slice(); - if (request) { - const currentEditingSession = chatEditingService.currentEditingSessionObs.get(); - const currentEdits = currentEditingSession?.entries.get(); - const currentEditCount = currentEdits?.length; - - if (currentEditingSession && currentEditCount) { - - const undecidedEdits = currentEdits.filter((edit) => edit.state.get() === WorkingSetEntryState.Modified); - if (undecidedEdits.length) { - const { result } = await dialogService.prompt({ - title: localize('chat.startEditing.confirmation.title', "Start new editing session?"), - message: localize('chat.startEditing.confirmation.pending.message.2', "Starting a new editing session will end your current session. Do you want to accept pending edits to {0} files?", undecidedEdits.length), - type: 'info', - buttons: [ - { - label: localize('chat.startEditing.confirmation.acceptEdits', "Accept & Continue"), - run: async () => { - await currentEditingSession.accept(); - return true; - } - }, - { - label: localize('chat.startEditing.confirmation.discardEdits', "Discard & Continue"), - run: async () => { - await currentEditingSession.reject(); - return true; - } - } - ], - }); - - if (!result) { - return; - } - } else { - const result = await dialogService.confirm({ - title: localize('chat.startEditing.confirmation.title', "Start new editing session?"), - message: currentEditCount - ? localize('chat.startEditing.confirmation.message.one', "Starting a new editing session will end your current editing session containing {0} file. Do you wish to proceed?", currentEditCount) - : localize('chat.startEditing.confirmation.message.many', "Starting a new editing session will end your current editing session containing {0} files. Do you wish to proceed?", currentEditCount), - type: 'info', - primaryButton: localize('chat.startEditing.confirmation.primaryButton', "Yes") - }); - - if (!result.confirmed) { - return; - } - } - - await currentEditingSession?.stop(); - const existingEditingChatWidget = chatWidgetService.getWidgetBySessionId(currentEditingSession.chatSessionId); - existingEditingChatWidget?.clear(); - existingEditingChatWidget?.attachmentModel.clear(); + // when a response is passed (clicked on) ignore all item after it + const [first] = args; + if (isResponseVM(first)) { + const idx = sourceRequests.findIndex(candidate => candidate.id === first.requestId); + if (idx >= 0) { + sourceRequests.length = idx + 1; } + } - const { widget } = await viewsService.openView(EDITS_VIEW_ID) as ChatViewPane; - if (widget.viewModel) { - const workingSetInputs = new ResourceSet(); - const message: IChatProgress[] = []; - for (const item of request.response?.response.value ?? []) { - if (item.kind === 'inlineReference') { - workingSetInputs.add(isLocation(item.inlineReference) ? item.inlineReference.uri : URI.isUri(item.inlineReference) ? item.inlineReference : item.inlineReference.location.uri); - } - - if (item.kind === 'textEditGroup') { - for (const group of item.edits) { - message.push({ - kind: 'textEdit', - edits: group, - uri: item.uri - }); - } - } else { - message.push(item); - } - } - - chatService.addCompleteRequest(widget.viewModel.sessionId, - request.message as IParsedChatRequest, - request.variableData, - request.attempt, - { - message, - result: request.response?.result, - followups: request.response?.followups - }); - - if (workingSetInputs.size) { - for (const reference of workingSetInputs) { - chatEditingService.currentEditingSessionObs.get()?.addFileToWorkingSet(reference); - } - } else { - for (const { reference } of request.response?.contentReferences ?? []) { - if (URI.isUri(reference) && [Schemas.file, Schemas.vscodeRemote].includes(reference.scheme)) { - chatEditingService.currentEditingSessionObs.get()?.addFileToWorkingSet(reference); - } - } - } + // when having multiple turns, let the user pick + if (sourceRequests.length > 1) { + const picks: (IQuickPickItem & { request: IChatRequestModel })[] = []; + for (const request of sourceRequests) { + picks.push({ + picked: true, + request: request, + label: request.message.text, + description: request.response?.response.toString() + }); } - widget.focusInput(); + const result = await quickPickService.pick(picks, { + placeHolder: localize('chat.startEditing.pickRequest', "Select requests that you want to use for editing"), + canPickMany: true + }); + if (!result) { + return; + } + sourceRequests = picks.map(pick => pick.request); + } + if (sourceRequests.length === 0) { + logService.trace('[CHAT_MOVE] NO requests to move'); + } + + await viewsService.openView(EditsViewId); + + let editingSession = chatEditingService.currentEditingSessionObs.get(); + if (!editingSession) { + await Event.toPromise(chatEditingService.onDidCreateEditingSession); + editingSession = chatEditingService.currentEditingSessionObs.get(); + return; + } + + if (!editingSession) { + return; + } + + const state = editingSession.state.get(); + if (state === ChatEditingSessionState.Disposed) { + return; + } + + // adopt request items and collect new working set entries + const workingSetAdditions = new ResourceSet(); + for (const request of sourceRequests) { + await chatService.adoptRequest(editingSession.chatSessionId, request); + this._collectWorkingSetAdditions(request, workingSetAdditions); + } + workingSetAdditions.forEach(uri => editingSession.addFileToWorkingSet(uri)); + + // make request + await chatService.sendRequest(editingSession.chatSessionId, '', { + agentId: editAgent.id, + acceptedConfirmationData: [{ _type: 'toEditTransfer', transferedTurnResults: sourceRequests.map(v => v.response?.result) }], // TODO@jrieken HACKY + confirmation: typeof this.desc.title === 'string' ? this.desc.title : this.desc.title.value + }); + } + + private _collectWorkingSetAdditions(request: IChatRequestModel, bucket: ResourceSet) { + for (const item of request.response?.response.value ?? []) { + if (item.kind === 'inlineReference') { + bucket.add(isLocation(item.inlineReference) + ? item.inlineReference.uri + : URI.isUri(item.inlineReference) + ? item.inlineReference + : item.inlineReference.location.uri + ); + } } } + }); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index b65aa16fb60..50db228ffcd 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -78,6 +78,7 @@ import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } f import { ChatGettingStartedContribution } from './actions/chatGettingStarted.js'; import { Extensions, IConfigurationMigrationRegistry } from '../../../common/configuration.js'; import { ChatEditorOverlayController } from './chatEditorOverlay.js'; +import { ChatRelatedFilesContribution } from './contrib/chatInputRelatedFilesContrib.js'; // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -310,8 +311,9 @@ registerWorkbenchContribution2(ChatSlashStaticSlashCommandsContribution.ID, Chat registerWorkbenchContribution2(ChatExtensionPointHandler.ID, ChatExtensionPointHandler, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(LanguageModelToolsExtensionPointHandler.ID, LanguageModelToolsExtensionPointHandler, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatCompatibilityNotifier.ID, ChatCompatibilityNotifier, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(ChatCommandCenterRendering.ID, ChatCommandCenterRendering, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatCommandCenterRendering.ID, ChatCommandCenterRendering, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatImplicitContextContribution.ID, ChatImplicitContextContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ChatRelatedFilesContribution.ID, ChatRelatedFilesContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatEditorSaving.ID, ChatEditorSaving, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatViewsWelcomeHandler.ID, ChatViewsWelcomeHandler, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 727728ab9bb..9762b500e2e 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -8,7 +8,6 @@ import { IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { Selection } from '../../../../editor/common/core/selection.js'; -import { localize } from '../../../../nls.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -37,19 +36,18 @@ export interface IChatWidgetService { readonly onDidAddWidget: Event; - getAllWidgets(location: ChatAgentLocation): ReadonlyArray; getWidgetByInputUri(uri: URI): IChatWidget | undefined; getWidgetBySessionId(sessionId: string): IChatWidget | undefined; - getWidgetByLocation(location: ChatAgentLocation): IChatWidget[]; + getWidgetsByLocations(location: ChatAgentLocation): ReadonlyArray; } export async function showChatView(viewsService: IViewsService): Promise { - return (await viewsService.openView(CHAT_VIEW_ID))?.widget; + return (await viewsService.openView(ChatViewId))?.widget; } export async function showEditsView(viewsService: IViewsService): Promise { - return (await viewsService.openView(EDITS_VIEW_ID))?.widget; + return (await viewsService.openView(EditsViewId))?.widget; } export const IQuickChatService = createDecorator('quickChatService'); @@ -213,8 +211,6 @@ export interface IChatCodeBlockContextProviderService { registerProvider(provider: ICodeBlockActionContextProvider, id: string): IDisposable; } -export const GeneratingPhrase = localize('generating', "Generating"); +export const ChatViewId = `workbench.panel.chat.view.${CHAT_PROVIDER_ID}`; -export const CHAT_VIEW_ID = `workbench.panel.chat.view.${CHAT_PROVIDER_ID}`; - -export const EDITS_VIEW_ID = 'workbench.panel.chat.view.edits'; +export const EditsViewId = 'workbench.panel.chat.view.edits'; diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts index 56480447516..9307985db0a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts @@ -41,7 +41,7 @@ export class ChatAttachmentModel extends Disposable { this.addContext(this.asVariableEntry(uri, range)); } - asVariableEntry(uri: URI, range?: IRange) { + asVariableEntry(uri: URI, range?: IRange): IChatRequestVariableEntry { return { value: range ? { uri, range } : uri, id: uri.toString() + (range?.toString() ?? ''), diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts index 9e1d1b3cd11..d5936b500bd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts @@ -19,7 +19,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { IOpenerService, OpenInternalOptions } from '../../../../../platform/opener/common/opener.js'; import { FolderThemeIcon, IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { ResourceLabels } from '../../../../browser/labels.js'; -import { revealInsideBarCommand } from '../../../files/browser/fileActions.contribution.js'; +import { revealInSideBarCommand } from '../../../files/browser/fileActions.contribution.js'; import { IChatRequestVariableEntry } from '../../common/chatModel.js'; import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../common/chatService.js'; @@ -172,7 +172,7 @@ export class ChatAttachmentsContentPart extends Disposable { private openResource(resource: URI, isDirectory?: boolean, range?: IRange): void { if (isDirectory) { // Reveal Directory in explorer - this.commandService.executeCommand(revealInsideBarCommand.id, resource); + this.commandService.executeCommand(revealInSideBarCommand.id, resource); return; } diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts index 839bc440386..5afb4bca615 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts @@ -42,12 +42,13 @@ import { IChatVariablesService } from '../../common/chatVariables.js'; import { IChatRendererContent, IChatResponseViewModel } from '../../common/chatViewModel.js'; import { ChatTreeItem, IChatWidgetService } from '../chat.js'; import { IDisposableReference, ResourcePool } from './chatCollections.js'; -import { IChatContentPart } from './chatContentParts.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; const $ = dom.$; export interface IChatReferenceListItem extends IChatContentReference { title?: string; + description?: string; state?: WorkingSetEntryState; } @@ -59,10 +60,12 @@ export class ChatCollapsibleListContentPart extends Disposable implements IChatC private readonly _onDidChangeHeight = this._register(new Emitter()); public readonly onDidChangeHeight = this._onDidChangeHeight.event; + private readonly hasFollowingContent: boolean; + constructor( private readonly data: ReadonlyArray, labelOverride: string | undefined, - element: IChatResponseViewModel, + context: IChatContentPartRenderContext, contentReferencesListPool: CollapsibleListPool, @IOpenerService openerService: IOpenerService, @IMenuService menuService: IMenuService, @@ -71,6 +74,8 @@ export class ChatCollapsibleListContentPart extends Disposable implements IChatC ) { super(); + const element = context.element as IChatResponseViewModel; + this.hasFollowingContent = context.contentIndex + 1 < context.content.length; const referencesLabel = labelOverride ?? (data.length > 1 ? localize('usedReferencesPlural', "Used {0} references", data.length) : localize('usedReferencesSingular', "Used {0} reference", 1)); @@ -162,8 +167,7 @@ export class ChatCollapsibleListContentPart extends Disposable implements IChatC } hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { - return other.kind === 'references' && other.references.length === this.data.length || - other.kind === 'codeCitations' && other.citations.length === this.data.length; + return other.kind === 'references' && other.references.length === this.data.length && (!!followingContent.length === this.hasFollowingContent); } private updateAriaLabel(element: HTMLElement, label: string, expanded?: boolean): void { @@ -382,12 +386,12 @@ class CollapsibleListRenderer implements IListRenderer | undefined; + let editsPromise: Promise | undefined; const editsSeen = new ResourceMap<{ seen: number }>(); const editedFilesExist = new ResourceMap>(); @@ -254,7 +255,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic }; - const handleResponseParts = (responseModel: IChatResponseModel) => { + const handleResponseParts = async (responseModel: IChatResponseModel) => { for (const part of responseModel.response.value) { if (part.kind === 'codeblockUri' || part.kind === 'textEditGroup') { // ensure editor is open asap @@ -283,7 +284,10 @@ export class ChatEditingService extends Disposable implements IChatEditingServic editsSource.emitOne({ uri: part.uri, edits: newEdits, kind: 'textEditGroup', done: part.kind === 'textEditGroup' && part.done }); if (first) { - this._continueEditingSession(async (builder, token) => { + + await editsPromise; + + editsPromise = this._continueEditingSession(async (builder, token) => { for await (const item of editsSource!.asyncIterable) { if (token.isCancellationRequested) { break; @@ -294,23 +298,25 @@ export class ChatEditingService extends Disposable implements IChatEditingServic builder.textEdits(item.uri, group, isLastGroup && (item.done ?? false), responseModel); } } - }, { silent: true }); + }, { silent: true }).finally(() => { + editsPromise = undefined; + }); } } } }; - observerDisposables.add(chatModel.onDidChange(e => { + observerDisposables.add(chatModel.onDidChange(async e => { if (e.kind === 'addRequest') { this._applyingChatEditsFailedContextKey.set(false); const responseModel = e.request.response; if (responseModel) { if (responseModel.isComplete) { - handleResponseParts(responseModel); + await handleResponseParts(responseModel); onResponseComplete(responseModel); } else { - const disposable = responseModel.onDidChange(() => { - handleResponseParts(responseModel); + const disposable = responseModel.onDidChange(async () => { + await handleResponseParts(responseModel); if (responseModel.isComplete) { onResponseComplete(responseModel); disposable.dispose(); @@ -387,6 +393,10 @@ export class ChatEditingService extends Disposable implements IChatEditingServic return editors; } + hasRelatedFilesProviders(): boolean { + return this._chatRelatedFilesProviders.size > 0; + } + registerRelatedFilesProvider(handle: number, provider: IChatRelatedFilesProvider): IDisposable { this._chatRelatedFilesProviders.set(handle, provider); return toDisposable(() => { @@ -394,28 +404,34 @@ export class ChatEditingService extends Disposable implements IChatEditingServic }); } - async getRelatedFiles(chatSessionId: string, prompt: string, token: CancellationToken): Promise { + async getRelatedFiles(chatSessionId: string, prompt: string, token: CancellationToken): Promise<{ group: string; files: IChatRelatedFile[] }[] | undefined> { const currentSession = this._currentSessionObs.get(); if (!currentSession || chatSessionId !== currentSession.chatSessionId) { return undefined; } - const currentWorkingSet = [...currentSession.workingSet.keys()]; + const userAddedWorkingSetEntries: URI[] = []; + for (const entry of currentSession.workingSet) { + // Don't incorporate suggested files into the related files request + // but do consider transient entries like open editors + if (entry[1].state !== WorkingSetEntryState.Suggested) { + userAddedWorkingSetEntries.push(entry[0]); + } + } const providers = Array.from(this._chatRelatedFilesProviders.values()); const result = await Promise.all(providers.map(async provider => { try { - return await raceTimeout(provider.provideRelatedFiles({ prompt, files: currentWorkingSet }, token), 2000); + const relatedFiles = await provider.provideRelatedFiles({ prompt, files: userAddedWorkingSetEntries }, token); + if (relatedFiles?.length) { + return { group: provider.description, files: relatedFiles }; + } + return undefined; } catch (e) { return undefined; } })); - return result.reduce((acc, cur) => { - if (cur) { - acc.push(...cur); - } - return acc; - }, []); + return coalesce(result); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 2566490bd6a..3ab832cd413 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -30,7 +30,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { MultiDiffEditor } from '../../../multiDiffEditor/browser/multiDiffEditor.js'; import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js'; import { ChatAgentLocation, IChatAgentService } from '../../common/chatAgents.js'; -import { ChatEditingSessionState, ChatEditKind, IChatEditingSession, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { ChatEditingSessionChangeType, ChatEditingSessionState, ChatEditKind, IChatEditingSession, WorkingSetDisplayMetadata, WorkingSetEntryState } from '../../common/chatEditingService.js'; import { IChatResponseModel } from '../../common/chatModel.js'; import { IChatWidgetService } from '../chat.js'; import { ChatEditingMultiDiffSourceResolver } from './chatEditingService.js'; @@ -59,14 +59,14 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } private readonly _sequencer = new Sequencer(); - private _workingSet = new ResourceMap(); + private _workingSet = new ResourceMap(); get workingSet() { this._assertNotDisposed(); // Return here a reunion between the AI modified entries and the user built working set - const result = new ResourceMap(this._workingSet); + const result = new ResourceMap(this._workingSet); for (const entry of this._entriesObs.get()) { - result.set(entry.modifiedURI, entry.state.get()); + result.set(entry.modifiedURI, { state: entry.state.get() }); } return result; @@ -101,7 +101,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return linearHistory.slice(linearHistoryIndex).map(s => s.requestId).filter((r): r is string => !!r); }); - private readonly _onDidChange = new Emitter(); + private readonly _onDidChange = new Emitter(); get onDidChange() { this._assertNotDisposed(); return this._onDidChange.event; @@ -155,7 +155,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio entries.forEach(entry => { entry.state.read(reader); }); - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); })); } @@ -164,7 +164,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio const existingTransientEntries = new ResourceSet(); for (const file of this._workingSet.keys()) { - if (this._workingSet.get(file) === WorkingSetEntryState.Transient) { + if (this._workingSet.get(file)?.state === WorkingSetEntryState.Transient) { existingTransientEntries.add(file); } } @@ -199,12 +199,12 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } for (const entry of activeEditors) { - this._workingSet.set(entry, WorkingSetEntryState.Transient); + this._workingSet.set(entry, { state: WorkingSetEntryState.Transient, description: localize('chatEditing.transient', "Open Editor") }); didChange = true; } if (didChange) { - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); } } @@ -213,7 +213,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio if (requestId) { this._snapshots.set(requestId, snapshot); for (const workingSetItem of this._workingSet.keys()) { - this._workingSet.set(workingSetItem, WorkingSetEntryState.Sent); + this._workingSet.set(workingSetItem, { state: WorkingSetEntryState.Sent }); } const linearHistory = this._linearHistory.get(); const linearHistoryIndex = this._linearHistoryIndex.get(); @@ -229,7 +229,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } private _createSnapshot(requestId: string | undefined): IChatEditingSessionSnapshot { - const workingSet = new ResourceMap(); + const workingSet = new ResourceMap(); for (const [file, state] of this._workingSet) { workingSet.set(file, state); } @@ -329,7 +329,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio continue; } didRemoveUris = this._workingSet.delete(uri) || didRemoveUris; - if (state === WorkingSetEntryState.Transient) { + if (state.state === WorkingSetEntryState.Transient || state.state === WorkingSetEntryState.Suggested) { this._removedTransientEntries.add(uri); } } @@ -338,7 +338,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return; // noop } - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); } private _assertNotDisposed(): void { @@ -361,7 +361,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } } - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.Other); } async reject(...uris: URI[]): Promise { @@ -378,7 +378,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } } - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.Other); } async show(): Promise { @@ -468,11 +468,17 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio this._sequencer.queue(() => this._resolve()); } - addFileToWorkingSet(resource: URI) { + addFileToWorkingSet(resource: URI, description?: string, proposedState?: WorkingSetEntryState.Suggested): void { const state = this._workingSet.get(resource); - if (state === undefined || state === WorkingSetEntryState.Transient) { - this._workingSet.set(resource, WorkingSetEntryState.Attached); - this._onDidChange.fire(); + if (!state && proposedState === WorkingSetEntryState.Suggested) { + if (this._removedTransientEntries.has(resource)) { + return; + } + this._workingSet.set(resource, { description, state: WorkingSetEntryState.Suggested }); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + } else if (state === undefined || state.state === WorkingSetEntryState.Transient) { + this._workingSet.set(resource, { description, state: WorkingSetEntryState.Attached }); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); } } @@ -551,7 +557,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } this._state.set(ChatEditingSessionState.Idle, tx); }); - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.Other); } private async _getOrCreateModifiedFileEntry(resource: URI, responseModel: IModifiedEntryTelemetryInfo): Promise { @@ -576,11 +582,11 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio this._entriesObs.set(newEntries, undefined); this._workingSet.delete(entry.modifiedURI); entry.dispose(); - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); })); const entriesArr = [...this._entriesObs.get(), entry]; this._entriesObs.set(entriesArr, undefined); - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); return entry; } @@ -614,6 +620,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio export interface IChatEditingSessionSnapshot { requestId: string | undefined; - workingSet: ResourceMap; + workingSet: ResourceMap; entries: ResourceMap; } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts index 5e2a44b7d94..e11b5c90d60 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts @@ -18,6 +18,8 @@ import { hasUndecidedChatEditingResourceContextKey, IChatEditingService } from ' import { ChatContextKeys } from '../common/chatContextKeys.js'; import { isEqual } from '../../../../base/common/resources.js'; import { Range } from '../../../../editor/common/core/range.js'; +import { getNotebookEditorFromEditorPane } from '../../notebook/browser/notebookBrowser.js'; +import { ctxNotebookHasEditorModification } from '../../notebook/browser/chatEdit/notebookChatEditController.js'; abstract class NavigateAction extends Action2 { @@ -36,7 +38,7 @@ abstract class NavigateAction extends Action2 { ? KeyMod.Alt | KeyCode.F5 : KeyMod.Alt | KeyMod.Shift | KeyCode.F5, weight: KeybindingWeight.EditorContrib, - when: ContextKeyExpr.and(ctxHasEditorModification, EditorContextKeys.focus), + when: ContextKeyExpr.and(ContextKeyExpr.or(ctxHasEditorModification, ctxNotebookHasEditorModification), EditorContextKeys.focus), }, f1: true, menu: { @@ -108,11 +110,9 @@ abstract class NavigateAction extends Action2 { abstract class AcceptDiscardAction extends Action2 { - constructor(readonly accept: boolean) { + constructor(id: string, readonly accept: boolean) { super({ - id: accept - ? 'chatEditor.action.accept' - : 'chatEditor.action.reject', + id, title: accept ? localize2('accept', 'Accept Chat Edit') : localize2('discard', 'Discard Chat Edit'), @@ -120,7 +120,7 @@ abstract class AcceptDiscardAction extends Action2 { ? localize2('accept2', 'Accept') : localize2('discard2', 'Discard'), category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(ChatContextKeys.requestInProgress.negate(), hasUndecidedChatEditingResourceContextKey, ctxHasEditorModification), + precondition: ContextKeyExpr.and(ChatContextKeys.requestInProgress.negate(), hasUndecidedChatEditingResourceContextKey, ContextKeyExpr.or(ctxHasEditorModification, ctxNotebookHasEditorModification)), icon: accept ? Codicon.check : Codicon.discard, @@ -144,24 +144,46 @@ abstract class AcceptDiscardAction extends Action2 { const chatEditingService = accessor.get(IChatEditingService); const editorService = accessor.get(IEditorService); - const editor = editorService.activeTextEditorControl; - if (!isCodeEditor(editor) || !editor.hasModel()) { + let uri = getNotebookEditorFromEditorPane(editorService.activeEditorPane)?.textModel?.uri; + if (!uri) { + const editor = editorService.activeTextEditorControl; + uri = isCodeEditor(editor) && editor.hasModel() ? editor.getModel().uri : undefined; + } + if (!uri) { return; } - const session = chatEditingService.getEditingSession(editor.getModel().uri); + const session = chatEditingService.getEditingSession(uri); if (!session) { return; } if (this.accept) { - session.accept(editor.getModel().uri); + session.accept(uri); } else { - session.reject(editor.getModel().uri); + session.reject(uri); } } } +export class AcceptAction extends AcceptDiscardAction { + + static readonly ID = 'chatEditor.action.accept'; + + constructor() { + super(AcceptAction.ID, true); + } +} + +export class RejectAction extends AcceptDiscardAction { + + static readonly ID = 'chatEditor.action.reject'; + + constructor() { + super(RejectAction.ID, false); + } +} + class UndoHunkAction extends EditorAction2 { constructor() { super({ @@ -212,8 +234,8 @@ class OpenDiffFromHunkAction extends EditorAction2 { export function registerChatEditorActions() { registerAction2(class NextAction extends NavigateAction { constructor() { super(true); } }); registerAction2(class PrevAction extends NavigateAction { constructor() { super(false); } }); - registerAction2(class AcceptAction extends AcceptDiscardAction { constructor() { super(true); } }); - registerAction2(class RejectAction extends AcceptDiscardAction { constructor() { super(false); } }); + registerAction2(AcceptAction); + registerAction2(RejectAction); registerAction2(UndoHunkAction); registerAction2(OpenDiffFromHunkAction); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorController.ts b/src/vs/workbench/contrib/chat/browser/chatEditorController.ts index 9a619ebe514..fb684a083b5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorController.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorController.ts @@ -27,11 +27,11 @@ import { minimapGutterAddedBackground, minimapGutterDeletedBackground, minimapGu import { ChatEditingSessionState, IChatEditingService, IModifiedFileEntry, WorkingSetEntryState } from '../common/chatEditingService.js'; import { Event } from '../../../../base/common/event.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { Position } from '../../../../editor/common/core/position.js'; import { Selection } from '../../../../editor/common/core/selection.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; export const ctxHasEditorModification = new RawContextKey('chat.hasEditorModifications', undefined, localize('chat.hasEditorModifications', "The current editor contains chat modifications")); @@ -185,6 +185,7 @@ export class ChatEditorController extends Disposable implements IEditorContribut const mightContainNonBasicASCII = originalModel.mightContainNonBasicASCII(); const mightContainRTL = originalModel.mightContainRTL(); const renderOptions = RenderOptions.fromEditor(this._editor); + const editorLineCount = this._editor.getModel()?.getLineCount(); for (const diffEntry of diff.changes) { const originalRange = diffEntry.original; @@ -202,11 +203,20 @@ export class ChatEditorController extends Disposable implements IEditorContribut diffDeleteDecoration.className!, InlineDecorationType.Regular )); - modifiedDecorations.push({ - range: i.modifiedRange, options: chatDiffAddDecoration - }); + + // If the original range is empty, the start line number is 1 and the new range spans the entire file, don't draw an Added decoration + if (!(i.originalRange.isEmpty() && i.originalRange.startLineNumber === 1 && i.modifiedRange.endLineNumber === editorLineCount) && !i.modifiedRange.isEmpty()) { + modifiedDecorations.push({ + range: i.modifiedRange, options: chatDiffAddDecoration + }); + } } - if (!diffEntry.modified.isEmpty) { + + // Render an added decoration but don't also render a deleted decoration for newly inserted content at the start of the file + // Note, this is a workaround for the `LineRange.isEmpty()` in diffEntry.original being `false` for newly inserted content + const isCreatedContent = decorations.length === 1 && decorations[0].range.isEmpty() && diffEntry.original.startLineNumber === 1; + + if (!diffEntry.modified.isEmpty && !(isCreatedContent && (diffEntry.modified.endLineNumberExclusive - 1) === editorLineCount)) { modifiedDecorations.push({ range: diffEntry.modified.toInclusiveRange()!, options: chatDiffWholeLineAddDecoration @@ -236,7 +246,6 @@ export class ChatEditorController extends Disposable implements IEditorContribut domNode.className = 'chat-editing-original-zone view-lines line-delete monaco-mouse-cursor-text'; const result = renderLines(source, renderOptions, decorations, domNode); - const isCreatedContent = decorations.length === 1 && decorations[0].range.isEmpty() && decorations[0].range.startLineNumber === 1; if (!isCreatedContent) { const viewZoneData: IViewZone = { afterLineNumber: diffEntry.modified.startLineNumber - 1, @@ -492,31 +501,17 @@ class DiffHunkWidget implements IOverlayWidget { this._domNode = document.createElement('div'); this._domNode.className = 'chat-diff-change-content-widget'; - const btns = instaService.createInstance(MenuWorkbenchButtonBar, this._domNode, MenuId.ChatEditingEditorHunk, { + const toolbar = instaService.createInstance(MenuWorkbenchToolBar, this._domNode, MenuId.ChatEditingEditorHunk, { telemetrySource: 'chatEditingEditorHunk', + hiddenItemStrategy: HiddenItemStrategy.NoHide, toolbarOptions: { primaryGroup: () => true, }, menuOptions: { renderShortTitle: true, arg: this, }, - buttonConfigProvider: (action) => { - if (action.id === 'chatEditor.action.undoHunk') { // TODO@jrieken don't hardcode id - return { - isSecondary: false, - showIcon: true, - showLabel: true - }; - } else { - return { - isSecondary: true, - showIcon: true, - showLabel: false - }; - } - } }); - this._store.add(btns); + this._store.add(toolbar); this._editor.addOverlayWidget(this); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts index b6f15fd73c9..3fb3f34dffe 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts @@ -5,14 +5,14 @@ import './media/chatEditorOverlay.css'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IReader, ISettableObservable, ITransaction, observableFromEvent, observableSignal, observableValue } from '../../../../base/common/observable.js'; +import { autorun, IReader, ISettableObservable, ITransaction, observableFromEvent, observableSignal, observableValue, transaction } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, OverlayWidgetPositionPreference } from '../../../../editor/browser/editorBrowser.js'; import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ChatEditingSessionState, IChatEditingService, IModifiedFileEntry, WorkingSetEntryState } from '../common/chatEditingService.js'; -import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, WorkingSetEntryState } from '../common/chatEditingService.js'; +import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { ActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { Range } from '../../../../editor/common/core/range.js'; @@ -22,6 +22,11 @@ import { EditorOption } from '../../../../editor/common/config/editorOptions.js' import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { assertType } from '../../../../base/common/types.js'; +import { localize } from '../../../../nls.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ctxNotebookHasEditorModification } from '../../notebook/browser/chatEdit/notebookChatEditController.js'; +import { AcceptAction, RejectAction } from './chatEditorActions.js'; class ChatEditorOverlayWidget implements IOverlayWidget { @@ -36,6 +41,8 @@ class ChatEditorOverlayWidget implements IOverlayWidget { private readonly _entry = observableValue<{ entry: IModifiedFileEntry; next: IModifiedFileEntry } | undefined>(this, undefined); + private readonly _navigationBearings = observableValue<{ changeCount: number; activeIdx: number }>(this, { changeCount: -1, activeIdx: -1 }); + constructor( private readonly _editor: ICodeEditor, @IEditorService editorService: IEditorService, @@ -63,7 +70,35 @@ class ChatEditorOverlayWidget implements IOverlayWidget { actionViewItemProvider: (action, options) => { const that = this; - if (action.id === 'chatEditor.action.accept' || action.id === 'chatEditor.action.reject') { + if (action.id === navigationBearingFakeActionId) { + return new class extends ActionViewItem { + + constructor() { + super(undefined, action, { ...options, icon: false, label: true, keybindingNotRenderedWithLabel: true }); + } + + override render(container: HTMLElement) { + super.render(container); + + container.classList.add('label-item'); + + this._store.add(autorun(r => { + assertType(this.label); + + const { changeCount, activeIdx } = that._navigationBearings.read(r); + const n = activeIdx === -1 ? '?' : `${activeIdx + 1}`; + const m = changeCount === -1 ? '?' : `${changeCount}`; + this.label.innerText = localize('nOfM', "{0} of {1}", n, m); + })); + } + + protected override getTooltip(): string | undefined { + return undefined; + } + }; + } + + if (action.id === AcceptAction.ID || action.id === RejectAction.ID) { return new class extends ActionViewItem { private readonly _reveal = this._store.add(new MutableDisposable()); @@ -129,21 +164,21 @@ class ChatEditorOverlayWidget implements IOverlayWidget { return { preference: OverlayWidgetPositionPreference.BOTTOM_RIGHT_CORNER }; } - show(entry: IModifiedFileEntry, next: IModifiedFileEntry) { + show(session: IChatEditingSession, activeEntry: IModifiedFileEntry, next: IModifiedFileEntry) { this._showStore.clear(); - this._entry.set({ entry, next }, undefined); + this._entry.set({ entry: activeEntry, next }, undefined); this._showStore.add(autorun(r => { - const busy = entry.isCurrentlyBeingModified.read(r); + const busy = activeEntry.isCurrentlyBeingModified.read(r); this._domNode.classList.toggle('busy', busy); })); const slickRatio = ObservableAnimatedValue.const(0); let t = Date.now(); this._showStore.add(autorun(r => { - const value = entry.rewriteRatio.read(r); + const value = activeEntry.rewriteRatio.read(r); slickRatio.changeAnimation(prev => { const result = new AnimatedValue(prev.getValue(), value, Date.now() - t); @@ -158,6 +193,40 @@ class ChatEditorOverlayWidget implements IOverlayWidget { ); })); + + const editorPositionObs = observableFromEvent(this._editor.onDidChangeCursorPosition, () => this._editor.getPosition()); + + this._showStore.add(autorun(r => { + const position = editorPositionObs.read(r); + + if (!position) { + return; + } + + const entries = session.entries.read(r); + + let changes = 0; + let activeIdx = -1; + for (const entry of entries) { + const diffInfo = entry.diffInfo.read(r); + + if (activeIdx !== -1 || entry !== activeEntry) { + // just add up + changes += diffInfo.changes.length; + + } else { + for (const change of diffInfo.changes) { + if (change.modified.includes(position.lineNumber)) { + activeIdx = changes; + } + changes += 1; + } + } + } + + this._navigationBearings.set({ changeCount: changes, activeIdx }, undefined); + })); + if (!this._isAdded) { this._editor.addOverlayWidget(this); this._isAdded = true; @@ -166,7 +235,10 @@ class ChatEditorOverlayWidget implements IOverlayWidget { hide() { - this._entry.set(undefined, undefined); + transaction(tx => { + this._entry.set(undefined, tx); + this._navigationBearings.set({ changeCount: -1, activeIdx: -1 }, tx); + }); if (this._isAdded) { this._editor.removeOverlayWidget(this); @@ -176,6 +248,20 @@ class ChatEditorOverlayWidget implements IOverlayWidget { } } +export const navigationBearingFakeActionId = 'chatEditor.navigation.bearings'; + +MenuRegistry.appendMenuItem(MenuId.ChatEditingEditorContent, { + command: { + id: navigationBearingFakeActionId, + title: localize('label', "Navigation Status"), + precondition: ContextKeyExpr.false(), + }, + when: ctxNotebookHasEditorModification.negate(), + group: 'navigate', + order: -1 +}); + + export class ObservableAnimatedValue { public static const(value: number): ObservableAnimatedValue { return new ObservableAnimatedValue(AnimatedValue.const(value)); @@ -315,7 +401,7 @@ export class ChatEditorOverlayController implements IEditorContribution { } const entry = entries[idx]; - widget.show(entry, entries[(idx + 1) % entries.length]); + widget.show(session, entry, entries[(idx + 1) % entries.length]); })); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts b/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts index 79fb39d5c5f..c5c62968a66 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts @@ -267,12 +267,11 @@ export class ChatEditingSaveAllAction extends Action2 { id: MenuId.ChatEditingWidgetToolbar, group: 'navigation', order: 2, - // Show the option to save without accepting if the user has autosave - // and also hasn't configured the setting to always save with generated changes + // Show the option to save without accepting if the user hasn't configured the setting to always save with generated changes when: ContextKeyExpr.and( applyingChatEditsFailedContextKey.negate(), ContextKeyExpr.or(hasUndecidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey.negate()), - ContextKeyExpr.notEquals('config.files.autoSave', 'off'), ContextKeyExpr.equals(`config.${ChatEditorSaving._config}`, false), + ContextKeyExpr.equals(`config.${ChatEditorSaving._config}`, false), ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession) ) } diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index b9c7505cb41..4f29de21897 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -71,7 +71,7 @@ import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/edit import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../codeEditor/browser/simpleEditorOptions.js'; -import { revealInsideBarCommand } from '../../files/browser/fileActions.contribution.js'; +import { revealInSideBarCommand } from '../../files/browser/fileActions.contribution.js'; import { ChatAgentLocation, IChatAgentService } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, WorkingSetEntryState } from '../common/chatEditingService.js'; @@ -882,7 +882,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private openResource(resource: URI, isDirectory?: boolean, range?: IRange): void { if (isDirectory) { // Reveal Directory in explorer - this.commandService.executeCommand(revealInsideBarCommand.id, resource); + this.commandService.executeCommand(revealInSideBarCommand.id, resource); return; } @@ -1014,7 +1014,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (!seenEntries.has(file)) { entries.unshift({ reference: file, - state: state, + state: state.state, + description: state.description, kind: 'reference', }); seenEntries.add(file); diff --git a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts index 0d0488dbf27..c80aaadddfd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @@ -51,7 +51,7 @@ import { IChatCodeCitations, IChatReferences, IChatRendererContent, IChatRequest import { getNWords } from '../common/chatWordCounter.js'; import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js'; import { MarkUnhelpfulActionId } from './actions/chatTitleActions.js'; -import { ChatTreeItem, GeneratingPhrase, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidgetService } from './chat.js'; +import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidgetService } from './chat.js'; import { ChatAgentHover, getChatAgentHoverOptions } from './chatAgentHover.js'; import { ChatAttachmentsContentPart } from './chatContentParts/chatAttachmentsContentPart.js'; import { ChatCodeCitationContentPart } from './chatContentParts/chatCodeCitationContentPart.js'; @@ -456,7 +456,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { this.updateItemHeight(templateData); })); @@ -856,7 +855,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - const hidden = this.storageService.getBoolean(MoveChatViewContribution.hideMovedChatWelcomeViewStorageKey, StorageScope.APPLICATION, false); - - // If the view is already hidden, then we just want to register keybindings. - if (hidden) { - this.registerKeybindings(); - return; - } - - await this.hideViewIfCopilotIsNotInstalled(); - this.updateContextKey(); - this.registerListeners(); - this.registerKeybindings(); - this.registerCommands(); - this.registerMovedChatWelcomeView(); - this.hideViewIfOldViewIsMovedFromDefaultLocation(); - } - - private markViewToHide(): void { - this.storageService.store(MoveChatViewContribution.hideMovedChatWelcomeViewStorageKey, true, StorageScope.APPLICATION, StorageTarget.USER); - this.updateContextKey(); - } - - private async hideViewIfCopilotIsNotInstalled(): Promise { - const extensions = await this.extensionManagementService.getInstalled(); - const installed = extensions.find(value => ExtensionIdentifier.equals(value.identifier.id, this.productService.gitHubEntitlement?.extensionId)); - if (!installed) { - this.markViewToHide(); - } - } - - private hideViewIfOldViewIsMovedFromDefaultLocation(): void { - // If the chat view is not actually moved to the new view container, then we should hide the welcome view. - const newViewContainer = this.viewDescriptorService.getViewContainerById(CHAT_SIDEBAR_PANEL_ID); - if (!newViewContainer) { - return; - } - - const currentChatViewContainer = this.viewDescriptorService.getViewContainerByViewId(CHAT_VIEW_ID); - if (currentChatViewContainer !== newViewContainer) { - this.markViewToHide(); - return; - } - - // If the chat view is in the new location, but the old view container was in the auxiliary bar anyway, then we should hide the welcome view. - const oldViewContainer = this.viewDescriptorService.getViewContainerById(CHAT_SIDEBAR_OLD_VIEW_PANEL_ID); - if (!oldViewContainer) { - return; - } - - const oldLocation = this.viewDescriptorService.getViewContainerLocation(oldViewContainer); - if (oldLocation === ViewContainerLocation.AuxiliaryBar) { - this.markViewToHide(); - } - } - - private updateContextKey(): void { - const hidden = this.storageService.getBoolean(MoveChatViewContribution.hideMovedChatWelcomeViewStorageKey, StorageScope.APPLICATION, false); - this.showWelcomeViewCtx.set(!hidden); - } - - private registerListeners(): void { - this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, MoveChatViewContribution.hideMovedChatWelcomeViewStorageKey, this._store)(() => this.updateContextKey())); - } - - private registerKeybindings(): void { - KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: CHAT_SIDEBAR_OLD_VIEW_PANEL_ID, - weight: KeybindingWeight.WorkbenchContrib, - when: ChatContextKeys.panelParticipantRegistered, - primary: 0, - handler: accessor => showChatView(accessor.get(IViewsService)) - }); - } - - private registerCommands(): void { - CommandsRegistry.registerCommand({ - id: '_chatMovedViewWelcomeView.ok', - handler: async (accessor: ServicesAccessor) => { - showChatView(accessor.get(IViewsService)); - this.markViewToHide(); - } - }); - - CommandsRegistry.registerCommand({ - id: '_chatMovedViewWelcomeView.restore', - handler: async () => { - const oldViewContainer = this.viewDescriptorService.getViewContainerById(CHAT_SIDEBAR_OLD_VIEW_PANEL_ID); - const newViewContainer = this.viewDescriptorService.getViewContainerById(CHAT_SIDEBAR_PANEL_ID); - if (!oldViewContainer || !newViewContainer) { - this.markViewToHide(); - return; - } - - const oldLocation = this.viewDescriptorService.getViewContainerLocation(oldViewContainer); - const newLocation = this.viewDescriptorService.getViewContainerLocation(newViewContainer); - - if (oldLocation === newLocation || oldLocation === null || newLocation === null) { - this.markViewToHide(); - return; - } - - const viewContainerIds = this.paneCompositePartService.getPaneCompositeIds(oldLocation); - const targetIndex = viewContainerIds.indexOf(oldViewContainer.id); - - this.viewDescriptorService.moveViewContainerToLocation(newViewContainer, oldLocation, targetIndex); - this.viewsService.openViewContainer(newViewContainer.id, true); - - this.markViewToHide(); - } - }); - - CommandsRegistry.registerCommand({ - id: '_chatMovedViewWelcomeView.learnMore', - handler: async (accessor: ServicesAccessor) => { - const openerService = accessor.get(IOpenerService); - openerService.open(URI.parse('https://aka.ms/vscode-secondary-sidebar')); - } - }); - } - - private registerMovedChatWelcomeView(): IDisposable { - // This is a welcome view container intended to show up where the old chat view was positioned to inform - // the user that we have changed the default location and how they can move it back or use the new location. - const title = localize2('chat.viewContainer.movedChat.label', "Chat (Old Location)"); - const icon = Codicon.commentDiscussion; - const viewContainerId = CHAT_SIDEBAR_OLD_VIEW_PANEL_ID; - const viewContainer: ViewContainer = Registry.as(ViewExtensions.ViewContainersRegistry).registerViewContainer({ - id: viewContainerId, - title, - icon, - ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [viewContainerId, { mergeViewWithContainerWhenSingleView: true }]), - storageId: viewContainerId, - hideIfEmpty: true, - order: 100, - }, ViewContainerLocation.Sidebar, { doNotRegisterOpenCommand: true }); - - const viewId = 'workbench.chat.movedView.welcomeView'; - const viewDescriptor: IViewDescriptor = { - id: viewId, - name: title, - order: 1, - canToggleVisibility: false, - canMoveView: false, - when: ContextKeyExpr.and(ChatContextKeys.shouldShowMovedViewWelcome, ContextKeyExpr.or(ChatContextKeys.panelParticipantRegistered, ChatContextKeys.extensionInvalid)), - ctorDescriptor: new SyncDescriptor(MovedChatViewPane, [{ id: viewId }]), - }; - - Registry.as(ViewExtensions.ViewsRegistry).registerViews([viewDescriptor], viewContainer); - - - const secondarySideBarLeft = this.configurationService.getValue('workbench.sideBar.location') !== 'left'; - - - let welcomeViewMainMessage = secondarySideBarLeft ? - localize('chatMovedMainMessage1Left', "Chat has been moved to the Secondary Side Bar on the left for a more integrated AI experience in your editor.") : - localize('chatMovedMainMessage1Right', "Chat has been moved to the Secondary Side Bar on the right for a more integrated AI experience in your editor."); - - const chatViewKeybinding = this.keybindingService.lookupKeybinding(CHAT_SIDEBAR_PANEL_ID)?.getLabel(); - const copilotIcon = `$(${this.productService.defaultChatAgent?.icon ?? 'comment-discussion'})`; - let quicklyAccessMessage = undefined; - if (this.hasCommandCenterChat() && chatViewKeybinding) { - quicklyAccessMessage = localize('chatMovedCommandCenterAndKeybind', "You can quickly access Chat via the new Copilot icon ({0}) in the editor title bar or with the keyboard shortcut {1}.", copilotIcon, chatViewKeybinding); - } else if (this.hasCommandCenterChat()) { - quicklyAccessMessage = localize('chatMovedCommandCenter', "You can quickly access Chat via the new Copilot icon ({0}) in the editor title bar.", copilotIcon); - } else if (chatViewKeybinding) { - quicklyAccessMessage = localize('chatMovedKeybind', "You can quickly access Chat with the keyboard shortcut {0}.", chatViewKeybinding); - } - - if (quicklyAccessMessage) { - welcomeViewMainMessage = `${welcomeViewMainMessage}\n\n${quicklyAccessMessage}`; - } - - const okButton = `[${localize('ok', "Got it")}](command:_chatMovedViewWelcomeView.ok)`; - const restoreButton = `[${localize('restore', "Restore Old Location")}](command:_chatMovedViewWelcomeView.restore)`; - - const welcomeViewFooterMessage = localize('chatMovedFooterMessage', "[Learn more](command:_chatMovedViewWelcomeView.learnMore) about the Secondary Side Bar."); - - const viewsRegistry = Registry.as(ViewExtensions.ViewsRegistry); - return viewsRegistry.registerViewWelcomeContent(viewId, { - content: [welcomeViewMainMessage, okButton, restoreButton, welcomeViewFooterMessage].join('\n\n'), - renderSecondaryButtons: true, - when: ContextKeyExpr.and(ChatContextKeys.shouldShowMovedViewWelcome, ContextKeyExpr.or(ChatContextKeys.panelParticipantRegistered, ChatContextKeys.extensionInvalid)) - }); - } - - private hasCommandCenterChat(): boolean { - if ( - this.configurationService.getValue('chat.commandCenter.enabled') === false || - this.configurationService.getValue('window.commandCenter') === false - ) { - return false; - } - - return true; - } -} - -registerWorkbenchContribution2(MoveChatViewContribution.ID, MoveChatViewContribution, WorkbenchPhase.BlockStartup); diff --git a/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts b/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts index fb3447f537a..5809333e2cd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts @@ -27,7 +27,7 @@ import { IExtension, IExtensionsWorkbenchService } from '../../extensions/common import { ChatAgentLocation, IChatAgentData, IChatAgentService } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { IRawChatParticipantContribution } from '../common/chatParticipantContribTypes.js'; -import { CHAT_VIEW_ID } from './chat.js'; +import { ChatViewId } from './chat.js'; import { CHAT_EDITING_SIDEBAR_PANEL_ID, CHAT_SIDEBAR_PANEL_ID, ChatViewPane } from './chatViewPane.js'; const chatParticipantExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint({ @@ -299,7 +299,7 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { // Register View. Name must be hardcoded because we want to show it even when the extension fails to load due to an API version incompatibility. const name = 'GitHub Copilot'; const viewDescriptor: IViewDescriptor[] = [{ - id: CHAT_VIEW_ID, + id: ChatViewId, containerIcon: this._viewContainer.icon, containerTitle: this._viewContainer.title.value, singleViewPaneContainerTitle: this._viewContainer.title.value, @@ -424,7 +424,7 @@ export class ChatCompatibilityNotifier extends Disposable implements IWorkbenchC const commandButton = `[${showExtensionLabel}](command:${showExtensionsWithIdsCommandId}?${encodeURIComponent(JSON.stringify([['GitHub.copilot-chat']]))})`; const versionMessage = `GitHub Copilot Chat version: ${chatExtension.version}`; const viewsRegistry = Registry.as(ViewExtensions.ViewsRegistry); - this._register(viewsRegistry.registerViewWelcomeContent(CHAT_VIEW_ID, { + this._register(viewsRegistry.registerViewWelcomeContent(ChatViewId, { content: [mainMessage, commandButton, versionMessage].join('\n\n'), when: ChatContextKeys.extensionInvalid, })); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSetup.contribution.ts index e837e8422eb..d422b95c281 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup.contribution.ts @@ -28,7 +28,7 @@ import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser import { IViewsService } from '../../../services/views/common/viewsService.js'; import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; import { CHAT_CATEGORY } from './actions/chatActions.js'; -import { showChatView, CHAT_VIEW_ID } from './chat.js'; +import { showChatView, ChatViewId } from './chat.js'; import { IChatAgentService } from '../common/chatAgents.js'; import { Event } from '../../../../base/common/event.js'; import product from '../../../../platform/product/common/product.js'; @@ -36,6 +36,8 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { IChatViewsWelcomeContributionRegistry, ChatViewsWelcomeExtensions } from './viewsWelcome/chatViewsWelcome.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../common/views.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; const defaultChat = { extensionId: product.defaultChatAgent?.extensionId ?? '', @@ -74,22 +76,22 @@ type InstallChatEvent = { class ChatSetupContribution extends Disposable implements IWorkbenchContribution { - private static readonly CHAT_EXTENSION_INSTALLED_KEY = 'chat.extensionInstalled'; + private readonly chatSetupSignedInContextKey = ChatContextKeys.ChatSetup.signedIn.bindTo(this.contextKeyService); + private readonly chatSetupEntitledContextKey = ChatContextKeys.ChatSetup.entitled.bindTo(this.contextKeyService); - private readonly chatSetupSignedInContextKey = ChatContextKeys.ChatSetup.signedIn.bindTo(this.contextService); - private readonly chatSetupEntitledContextKey = ChatContextKeys.ChatSetup.entitled.bindTo(this.contextService); + private readonly chatSetupState = this.instantiationService.createInstance(ChatSetupState); private resolvedEntitlement: boolean | undefined = undefined; constructor( - @IContextKeyService private readonly contextService: IContextKeyService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IAuthenticationService private readonly authenticationService: IAuthenticationService, @IProductService private readonly productService: IProductService, @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, @IExtensionService private readonly extensionService: IExtensionService, @IRequestService private readonly requestService: IRequestService, - @IStorageService private readonly storageService: IStorageService + @IInstantiationService private readonly instantiationService: IInstantiationService ) { super(); @@ -98,19 +100,12 @@ class ChatSetupContribution extends Disposable implements IWorkbenchContribution return; } - this.checkExtensionInstallation(entitlement); - this.registerChatWelcome(); this.registerEntitlementListeners(entitlement); this.registerAuthListeners(entitlement); - } - private async checkExtensionInstallation(entitlement: IGitHubEntitlement): Promise { - const extensions = await this.extensionManagementService.getInstalled(); - - const installed = extensions.find(value => ExtensionIdentifier.equals(value.identifier.id, entitlement.extensionId)); - this.updateExtensionInstalled(installed ? true : false); + this.checkExtensionInstallation(entitlement); } private registerChatWelcome(): void { @@ -119,7 +114,7 @@ class ChatSetupContribution extends Disposable implements IWorkbenchContribution Registry.as(ChatViewsWelcomeExtensions.ChatViewsWelcomeRegistry).register({ title: defaultChat.welcomeTitle, when: ContextKeyExpr.and( - ChatContextKeys.ChatSetup.triggering, + ChatContextKeys.ChatSetup.triggered, ChatContextKeys.ChatSetup.signedIn.negate(), ChatContextKeys.ChatSetup.signingIn.negate(), ChatContextKeys.ChatSetup.installing.negate(), @@ -127,14 +122,14 @@ class ChatSetupContribution extends Disposable implements IWorkbenchContribution ChatContextKeys.panelParticipantRegistered.negate() )!, icon: defaultChat.icon, - content: new MarkdownString(`[${localize('signInAndSetup', "Sign in to use {0}", defaultChat.name)}](command:${ChatSetupSignInAndInstallChatAction.ID})\n\n[${localize('learnMore', "Learn More")}](${defaultChat.documentationUrl})`, { isTrusted: true }), + content: new MarkdownString(`${localize('setupContent', "{0} is your AI pair programmer that helps you write code faster and smarter.", defaultChat.name)}\n\n[${localize('signInAndSetup', "Sign in to use {0}", defaultChat.name)}](command:${ChatSetupSignInAndInstallChatAction.ID})\n\n[${localize('learnMore', "Learn More")}](${defaultChat.documentationUrl}) | [${localize('hideSetup', "Hide")}](command:${ChatSetupHideAction.ID} "${localize('hideSetup', "Hide")}")`, { isTrusted: true }), }); // Setup: Triggered (signed-in) Registry.as(ChatViewsWelcomeExtensions.ChatViewsWelcomeRegistry).register({ title: defaultChat.welcomeTitle, when: ContextKeyExpr.and( - ChatContextKeys.ChatSetup.triggering, + ChatContextKeys.ChatSetup.triggered, ChatContextKeys.ChatSetup.signedIn, ChatContextKeys.ChatSetup.signingIn.negate(), ChatContextKeys.ChatSetup.installing.negate(), @@ -142,7 +137,7 @@ class ChatSetupContribution extends Disposable implements IWorkbenchContribution ChatContextKeys.panelParticipantRegistered.negate() )!, icon: defaultChat.icon, - content: new MarkdownString(`[${localize('setup', "Install {0}", defaultChat.name)}](command:${ChatSetupInstallAction.ID})\n\n[${localize('learnMore', "Learn More")}](${defaultChat.documentationUrl})`, { isTrusted: true }), + content: new MarkdownString(`${localize('setupContent', "{0} is your AI pair programmer that helps you write code faster and smarter.", defaultChat.name)}\n\n[${localize('setup', "Install {0}", defaultChat.name)}](command:${ChatSetupInstallAction.ID})\n\n[${localize('learnMore', "Learn More")}](${defaultChat.documentationUrl}) | [${localize('hideSetup', "Hide")}](command:${ChatSetupHideAction.ID} "${localize('hideSetup', "Hide")}")`, { isTrusted: true }), }); // Setup: Signing-in @@ -155,7 +150,7 @@ class ChatSetupContribution extends Disposable implements IWorkbenchContribution )!, icon: defaultChat.icon, progress: localize('setupChatSigningIn', "Signing in to {0}...", defaultChat.providerName), - content: new MarkdownString(`\n\n[${localize('learnMore', "Learn More")}](${defaultChat.documentationUrl})`, { isTrusted: true }), + content: new MarkdownString(`${localize('setupContent', "{0} is your AI pair programmer that helps you write code faster and smarter.", defaultChat.name)}`, { isTrusted: true }), }); // Setup: Installing @@ -164,7 +159,7 @@ class ChatSetupContribution extends Disposable implements IWorkbenchContribution when: ChatContextKeys.ChatSetup.installing, icon: defaultChat.icon, progress: localize('setupChatInstalling', "Setting up Chat for you..."), - content: new MarkdownString(`\n\n[${localize('learnMore', "Learn More")}](${defaultChat.documentationUrl})`, { isTrusted: true }), + content: new MarkdownString(`${localize('setupContent', "{0} is your AI pair programmer that helps you write code faster and smarter.", defaultChat.name)}`, { isTrusted: true }), }); } @@ -172,14 +167,14 @@ class ChatSetupContribution extends Disposable implements IWorkbenchContribution this._register(this.extensionService.onDidChangeExtensions(result => { for (const extension of result.removed) { if (ExtensionIdentifier.equals(entitlement.extensionId, extension.identifier)) { - this.updateExtensionInstalled(false); + this.chatSetupState.update({ chatInstalled: false }); break; } } for (const extension of result.added) { if (ExtensionIdentifier.equals(entitlement.extensionId, extension.identifier)) { - this.updateExtensionInstalled(true); + this.chatSetupState.update({ chatInstalled: true }); break; } } @@ -281,8 +276,59 @@ class ChatSetupContribution extends Disposable implements IWorkbenchContribution return this.resolvedEntitlement; } - private updateExtensionInstalled(isExtensionInstalled: boolean): void { - this.storageService.store(ChatSetupContribution.CHAT_EXTENSION_INSTALLED_KEY, isExtensionInstalled, StorageScope.PROFILE, StorageTarget.MACHINE); + private async checkExtensionInstallation(entitlement: IGitHubEntitlement): Promise { + const extensions = await this.extensionManagementService.getInstalled(); + + const chatInstalled = !!extensions.find(value => ExtensionIdentifier.equals(value.identifier.id, entitlement.extensionId)); + this.chatSetupState.update({ chatInstalled }); + } +} + +class ChatSetupState { + + private static readonly CHAT_SETUP_TRIGGERD = 'chat.setupTriggered'; + private static readonly CHAT_EXTENSION_INSTALLED = 'chat.extensionInstalled'; + + private readonly chatSetupTriggeredContext = ChatContextKeys.ChatSetup.triggered.bindTo(this.contextKeyService); + + constructor( + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService + ) { + this.updateContext(); + } + + update(context: { triggered: boolean }): void; + update(context: { chatInstalled?: boolean }): void; + update(context: { triggered?: boolean; chatInstalled?: boolean }): void { + if (typeof context.chatInstalled === 'boolean') { + this.storageService.store(ChatSetupState.CHAT_EXTENSION_INSTALLED, context.chatInstalled, StorageScope.PROFILE, StorageTarget.MACHINE); + } + + if (typeof context.triggered === 'boolean') { + if (context.triggered) { + this.storageService.store(ChatSetupState.CHAT_SETUP_TRIGGERD, true, StorageScope.PROFILE, StorageTarget.MACHINE); + } else { + this.storageService.remove(ChatSetupState.CHAT_SETUP_TRIGGERD, StorageScope.PROFILE); + } + } + + this.updateContext(); + } + + private updateContext(): void { + const chatSetupTriggered = this.storageService.getBoolean(ChatSetupState.CHAT_SETUP_TRIGGERD, StorageScope.PROFILE, false); + const chatInstalled = this.storageService.getBoolean(ChatSetupState.CHAT_EXTENSION_INSTALLED, StorageScope.PROFILE, false); + + const showChatSetup = chatSetupTriggered && !chatInstalled; + if (showChatSetup) { + // this is ugly but fixes flicker from a previous chat install + this.storageService.remove('chat.welcomeMessageContent.panel', StorageScope.APPLICATION); + this.storageService.remove('interactive.sessions', this.workspaceContextService.getWorkspace().folders.length ? StorageScope.WORKSPACE : StorageScope.APPLICATION); + } + + this.chatSetupTriggeredContext.set(showChatSetup); } } @@ -300,14 +346,46 @@ class ChatSetupTriggerAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - const contextKeyService = accessor.get(IContextKeyService); const viewsService = accessor.get(IViewsService); + const instantiationService = accessor.get(IInstantiationService); + + instantiationService.createInstance(ChatSetupState).update({ triggered: true }); - ChatContextKeys.ChatSetup.triggering.bindTo(contextKeyService).set(true); showChatView(viewsService); } } +class ChatSetupHideAction extends Action2 { + + static readonly ID = 'workbench.action.chat.hideSetup'; + static readonly TITLE = localize2('hideChatSetup', "Hide Chat Setup"); + + constructor() { + super({ + id: ChatSetupHideAction.ID, + title: ChatSetupHideAction.TITLE, + f1: false + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const viewsDescriptorService = accessor.get(IViewDescriptorService); + const layoutService = accessor.get(IWorkbenchLayoutService); + const instantiationService = accessor.get(IInstantiationService); + + const location = viewsDescriptorService.getViewLocationById(ChatViewId); + + instantiationService.createInstance(ChatSetupState).update({ triggered: false }); + + if (location === ViewContainerLocation.AuxiliaryBar) { + const activeContainers = viewsDescriptorService.getViewContainersByLocation(location).filter(container => viewsDescriptorService.getViewContainerModel(container).activeViewDescriptors.length > 0); + if (activeContainers.length === 0) { + layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); // hide if there are no views in the secondary sidebar + } + } + } +} + class ChatSetupInstallAction extends Action2 { static readonly ID = 'workbench.action.chat.install'; @@ -320,8 +398,8 @@ class ChatSetupInstallAction extends Action2 { category: CHAT_CATEGORY, menu: { id: MenuId.ChatCommandCenter, - group: 'a_atfirst', - order: 1, + group: 'a_open', + order: 0, when: ContextKeyExpr.and( ChatContextKeys.panelParticipantRegistered.negate(), ContextKeyExpr.or( @@ -356,7 +434,7 @@ class ChatSetupInstallAction extends Action2 { enable: true, isMachineScoped: false, installPreReleaseVersion: productService.quality !== 'stable' - }, CHAT_VIEW_ID); + }, ChatViewId); installResult = 'installed'; } catch (error) { @@ -384,8 +462,8 @@ class ChatSetupSignInAndInstallChatAction extends Action2 { category: CHAT_CATEGORY, menu: { id: MenuId.ChatCommandCenter, - group: 'a_atfirst', - order: 1, + group: 'a_open', + order: 0, when: ContextKeyExpr.and( ChatContextKeys.panelParticipantRegistered.negate(), ChatContextKeys.ChatSetup.entitled.negate(), @@ -430,6 +508,7 @@ class ChatSetupSignInAndInstallChatAction extends Action2 { } registerAction2(ChatSetupTriggerAction); +registerAction2(ChatSetupHideAction); registerAction2(ChatSetupInstallAction); registerAction2(ChatSetupSignInAndInstallChatAction); diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index 03abf59e044..e5e5f9f75d0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -58,11 +58,6 @@ import { ChatViewWelcomePart } from './viewsWelcome/chatViewWelcomeController.js const $ = dom.$; -function revealLastElement(list: WorkbenchObjectTree) { - const newScrollTop = list.scrollHeight - list.renderHeight; - list.scrollTop = newScrollTop; -} - export interface IChatViewState { inputValue?: string; inputState?: IChatInputState; @@ -146,6 +141,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private tree!: WorkbenchObjectTree; private renderer!: ChatListItemRenderer; private readonly _codeBlockModelCollection: CodeBlockModelCollection; + private lastItem: ChatTreeItem | undefined; private inputPart!: ChatInputPart; private editorOptions!: ChatEditorOptions; @@ -434,7 +430,7 @@ export class ChatWidget extends Disposable implements IChatWidget { scrollDownButton.setTitle(localize('scrollDownButtonLabel', "Scroll down")); this._register(scrollDownButton.onDidClick(() => { this.scrollLock = true; - revealLastElement(this.tree); + this.scrollToEnd(); })); this._register(this.editorOptions.onDidChange(() => this.onDidStyleChange())); @@ -443,7 +439,7 @@ export class ChatWidget extends Disposable implements IChatWidget { // Do initial render if (this.viewModel) { this.onDidChangeItems(); - revealLastElement(this.tree); + this.scrollToEnd(); } this.contribs = ChatWidget.CONTRIBS.map(contrib => { @@ -459,6 +455,13 @@ export class ChatWidget extends Disposable implements IChatWidget { this._register((this.chatWidgetService as ChatWidgetService).register(this)); } + private scrollToEnd() { + if (this.lastItem) { + const offset = Math.max(this.lastItem.currentRenderedHeight ?? 0, 1e6); + this.tree.reveal(this.lastItem, offset); + } + } + getContrib(id: string): T | undefined { return this.contribs.find(c => c.id === id) as T; } @@ -545,12 +548,12 @@ export class ChatWidget extends Disposable implements IChatWidget { this.layoutDynamicChatTreeItemMode(); } - const lastItem = treeItems[treeItems.length - 1]?.element; - if (lastItem) { - ChatContextKeys.lastItemId.bindTo(this.contextKeyService).set([lastItem.id]); + this.lastItem = treeItems[treeItems.length - 1]?.element; + if (this.lastItem) { + ChatContextKeys.lastItemId.bindTo(this.contextKeyService).set([this.lastItem.id]); } - if (lastItem && isResponseVM(lastItem) && lastItem.isComplete) { - this.renderFollowups(lastItem.replyFollowups, lastItem); + if (this.lastItem && isResponseVM(this.lastItem) && this.lastItem.isComplete) { + this.renderFollowups(this.lastItem.replyFollowups, this.lastItem); } else if (!treeItems.length && this.viewModel) { this.renderFollowups(this.viewModel.model.sampleQuestions); } else { @@ -738,10 +741,7 @@ export class ChatWidget extends Disposable implements IChatWidget { dom.scheduleAtNextAnimationFrame(dom.getWindow(this.listContainer), () => { // Can't set scrollTop during this event listener, the list might overwrite the change - // TODO This doesn't necessarily work on the first try because of the dynamic heights of items and - // the way ListView works. Twice is usually enough. But this would work better if this lived inside ListView somehow - revealLastElement(this.tree); - revealLastElement(this.tree); + this.scrollToEnd(); }, 0); } } @@ -868,7 +868,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.onDidChangeItems(); if (events.some(e => e?.kind === 'addRequest') && this.visible) { - revealLastElement(this.tree); // Now we know how big they actually are... how do we know that 2 rounds is enough + this.scrollToEnd(); } if (this.chatEditingService.currentEditingSession && this.chatEditingService.currentEditingSession?.chatSessionId === this.viewModel?.sessionId) { @@ -903,7 +903,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (this.tree && this.visible) { this.onDidChangeItems(); - revealLastElement(this.tree); + this.scrollToEnd(); } this.updateChatInputContext(); @@ -1138,7 +1138,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const lastItem = this.viewModel?.getItems().at(-1); const lastResponseIsRendering = isResponseVM(lastItem) && lastItem.renderData; if (lastElementVisible && (!lastResponseIsRendering || this.viewOptions.autoScroll)) { - revealLastElement(this.tree); + this.scrollToEnd(); } this.listContainer.style.height = `${listHeight}px`; @@ -1241,8 +1241,7 @@ export class ChatWidget extends Disposable implements IChatWidget { ); if (needsRerender || !listHeight) { - // TODO: figure out a better place to reveal the last element - revealLastElement(this.tree); + this.scrollToEnd(); } } @@ -1282,7 +1281,7 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService return this._lastFocusedWidget; } - getAllWidgets(location: ChatAgentLocation): ReadonlyArray { + getWidgetsByLocations(location: ChatAgentLocation): ReadonlyArray { return this._widgets.filter(w => w.location === location); } @@ -1290,10 +1289,6 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService return this._widgets.find(w => isEqual(w.inputUri, uri)); } - getWidgetByLocation(location: ChatAgentLocation): ChatWidget[] { - return this._widgets.filter(w => w.location === location); - } - getWidgetBySessionId(sessionId: string): ChatWidget | undefined { return this._widgets.find(w => w.viewModel?.sessionId === sessionId); } diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts index 6d90f0f6ed2..ed33aec41d9 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts @@ -115,7 +115,7 @@ export class ChatImplicitContextContribution extends Disposable implements IWork return; } - const widgets = updateWidget ? [updateWidget] : [...this.chatWidgetService.getAllWidgets(ChatAgentLocation.Panel), ...this.chatWidgetService.getAllWidgets(ChatAgentLocation.Editor)]; + const widgets = updateWidget ? [updateWidget] : [...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Panel), ...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Editor)]; for (const widget of widgets) { if (widget.input.implicitContext) { widget.input.implicitContext.setValue(newValue, isSelection); diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts index e6881751522..917561dfaee 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { isPatternInWord } from '../../../../../base/common/filters.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; @@ -486,18 +487,23 @@ class BuiltinDynamicCompletions extends Disposable { private async addFileEntries(widget: IChatWidget, result: CompletionList, info: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, token: CancellationToken) { - const makeFileCompletionItem = (resource: URI): CompletionItem => { + const makeFileCompletionItem = (resource: URI, description?: string): CompletionItem => { const basename = this.labelService.getUriBasenameLabel(resource); const text = `${chatVariableLeader}file:${basename}`; + const uriLabel = this.labelService.getUriLabel(resource, { relative: true }); + const labelDescription = description + ? localize('fileEntryDescription', '{0} ({1})', uriLabel, description) + : uriLabel; + const sortText = description ? 'z' : '{'; // after `z` return { - label: { label: basename, description: this.labelService.getUriLabel(resource, { relative: true }) }, + label: { label: basename, description: labelDescription }, filterText: `${chatVariableLeader}${basename}`, insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text, range: info, kind: CompletionItemKind.File, - sortText: '{', // after `z` + sortText, command: { id: BuiltinDynamicCompletions.addReferenceCommand, title: '', arguments: [new ReferenceArgument(widget, { id: 'vscode.file', @@ -518,6 +524,20 @@ class BuiltinDynamicCompletions extends Disposable { const seen = new ResourceSet(); const len = result.suggestions.length; + // RELATED FILES + if (widget.location === ChatAgentLocation.EditingSession && widget.viewModel && this._chatEditingService.currentEditingSessionObs.get()?.chatSessionId === widget.viewModel?.sessionId) { + const relatedFiles = (await raceTimeout(this._chatEditingService.getRelatedFiles(widget.viewModel.sessionId, widget.getInput(), token), 1000)) ?? []; + for (const relatedFileGroup of relatedFiles) { + for (const relatedFile of relatedFileGroup.files) { + if (seen.has(relatedFile.uri)) { + continue; + } + seen.add(relatedFile.uri); + result.suggestions.push(makeFileCompletionItem(relatedFile.uri, relatedFile.description)); + } + } + } + // HISTORY // always take the last N items for (const item of this.historyService.getHistory()) { @@ -576,16 +596,6 @@ class BuiltinDynamicCompletions extends Disposable { } } - // RELATED FILES - if (widget.location === ChatAgentLocation.EditingSession && widget.viewModel && this._chatEditingService.currentEditingSessionObs.get()?.chatSessionId === widget.viewModel?.sessionId) { - for (const relatedFile of (await this._chatEditingService.getRelatedFiles(widget.viewModel.sessionId, widget.getInput(), token) ?? [])) { - if (seen.has(relatedFile.uri)) { - continue; - } - result.suggestions.push(makeFileCompletionItem(relatedFile.uri)); - } - } - // mark results as incomplete because further typing might yield // in more search results result.incomplete = true; diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputRelatedFilesContrib.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputRelatedFilesContrib.ts new file mode 100644 index 00000000000..f61099ebc7f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputRelatedFilesContrib.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ResourceSet } from '../../../../../base/common/map.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { ChatEditingSessionChangeType, IChatEditingService, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { IChatWidgetService } from '../chat.js'; + +export class ChatRelatedFilesContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'chat.relatedFilesWorkingSet'; + + private readonly chatEditingSessionDisposables = new DisposableStore(); + private _currentRelatedFilesRetrievalOperation: Promise | undefined; + + constructor( + @IChatEditingService private readonly chatEditingService: IChatEditingService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService + ) { + super(); + + this._handleNewEditingSession(); + this._register(this.chatEditingService.onDidCreateEditingSession(() => { + this.chatEditingSessionDisposables.clear(); + this._handleNewEditingSession(); + })); + } + + private _updateRelatedFileSuggestions() { + if (this._currentRelatedFilesRetrievalOperation) { + return; + } + + const currentEditingSession = this.chatEditingService.currentEditingSessionObs.get(); + if (currentEditingSession) { + const workingSetEntries = currentEditingSession.entries.get(); + if (workingSetEntries.length > 0) { + // Do this only for the initial working set state + return; + } + + const widget = this.chatWidgetService.getWidgetBySessionId(currentEditingSession.chatSessionId); + if (!widget) { + return; + } + + this._currentRelatedFilesRetrievalOperation = this.chatEditingService.getRelatedFiles(currentEditingSession.chatSessionId, widget.getInput(), CancellationToken.None) + .then((files) => { + if (!files?.length) { + return; + } + + const currentEditingSession = this.chatEditingService.currentEditingSessionObs.get(); + if (!currentEditingSession || currentEditingSession.chatSessionId !== widget.viewModel?.sessionId || currentEditingSession.entries.get()) { + return; // Might have disposed while we were calculating + } + + // Pick up to 2 related files, or however many we can still fit in the working set + const maximumRelatedFiles = Math.min(2, this.chatEditingService.editingSessionFileLimit - widget.input.chatEditWorkingSetFiles.length); + const newSuggestions = new ResourceSet(); + for (const group of files) { + for (const file of group.files) { + if (newSuggestions.size >= maximumRelatedFiles) { + break; + } + newSuggestions.add(file.uri); + } + } + + // Remove the existing related file suggestions from the working set + const existingSuggestedEntriesToRemove: URI[] = []; + for (const entry of currentEditingSession.workingSet) { + if (entry[1].state === WorkingSetEntryState.Suggested && !newSuggestions.has(entry[0])) { + existingSuggestedEntriesToRemove.push(entry[0]); + } + } + currentEditingSession?.remove(...existingSuggestedEntriesToRemove); + + // Add the new related file suggestions to the working set + for (const file of newSuggestions) { + currentEditingSession.addFileToWorkingSet(file, localize('relatedFile', "Suggested File"), WorkingSetEntryState.Suggested); + } + }) + .finally(() => { + this._currentRelatedFilesRetrievalOperation = undefined; + }); + } + } + + private _handleNewEditingSession() { + const currentEditingSession = this.chatEditingService.currentEditingSessionObs.get(); + if (!currentEditingSession) { + return; + } + const widget = this.chatWidgetService.getWidgetBySessionId(currentEditingSession.chatSessionId); + if (!widget || widget.viewModel?.sessionId !== currentEditingSession.chatSessionId) { + return; + } + this.chatEditingSessionDisposables.add(currentEditingSession.onDidDispose(() => { + this.chatEditingSessionDisposables.clear(); + })); + this._updateRelatedFileSuggestions(); + const onDebouncedType = Event.debounce(widget.inputEditor.onDidChangeModelContent, () => null, 3000); + this.chatEditingSessionDisposables.add(onDebouncedType(() => { + this._updateRelatedFileSuggestions(); + })); + this.chatEditingSessionDisposables.add(currentEditingSession.onDidChange((e) => { + if (e === ChatEditingSessionChangeType.WorkingSet) { + this._updateRelatedFileSuggestions(); + } + })); + } + + override dispose() { + this.chatEditingSessionDisposables.dispose(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index 120c42a6e81..15cb95d53d4 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -1213,6 +1213,10 @@ have to be updated for changes to the rules above, or to support more deeply nes gap: 8px; margin: 0 0 6px 4px; + > .codicon { + height: 16px; + } + .codicon { /* Very aggressive list styles try to apply focus colors to every codicon in a list row. */ color: var(--vscode-icon-foreground) !important; diff --git a/src/vs/workbench/contrib/chat/browser/media/chatEditorController.css b/src/vs/workbench/contrib/chat/browser/media/chatEditorController.css index 657ace17441..03d8b6e6171 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatEditorController.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatEditorController.css @@ -13,13 +13,27 @@ opacity: 1; } -.chat-diff-change-content-widget .monaco-text-button { - padding: 0 2px; - z-index: 9; - font-size: 11px; - line-height: 16px; +.chat-diff-change-content-widget .monaco-action-bar { + padding: 0; + border-radius: 2px; + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); } -.chat-diff-change-content-widget .monaco-text-button .codicon[class*='codicon-'] { - font-size: 11px; +.chat-diff-change-content-widget .monaco-action-bar .action-item .action-label { + height: 14px; + border-radius: 2px; + color: var(--vscode-button-foreground); +} + +.chat-diff-change-content-widget .monaco-action-bar .action-item .action-label.codicon { + width: unset; + padding: 2px; + font-size: 12px; + line-height: 14px; + color: var(--vscode-button-foreground); +} + +.chat-diff-change-content-widget .monaco-action-bar .action-item .action-label.codicon[class*='codicon-'] { + font-size: 12px; } diff --git a/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css b/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css index 564b48a3264..7091003012f 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css @@ -18,6 +18,7 @@ display: none; padding: 0px 5px; font-size: 12px; + font-variant-numeric: tabular-nums; } .chat-editor-overlay-widget.busy .chat-editor-overlay-progress { @@ -49,3 +50,14 @@ color: var(--vscode-button-foreground); opacity: 0.7; } + + +.chat-editor-overlay-widget .action-item.label-item { + font-variant-numeric: tabular-nums; +} + +.chat-editor-overlay-widget .action-item.label-item > .action-label, +.chat-editor-overlay-widget .action-item.label-item > .action-label:hover { + color: var(--vscode-button-foreground); + opacity: 1; +} diff --git a/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts b/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts index bae1465625e..81dd0149a76 100644 --- a/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts +++ b/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts @@ -57,14 +57,15 @@ export class ChatViewWelcomeController extends Disposable { return; } + this.enabled = enabled; this.enabledDisposables.clear(); + if (!enabled) { this.container.classList.toggle('chat-view-welcome-visible', false); this.renderDisposables.clear(); return; } - this.enabled = true; const descriptors = chatViewsWelcomeRegistry.get(); if (descriptors.length) { this.render(descriptors); @@ -134,14 +135,6 @@ export class ChatViewWelcomePart extends Disposable { featureIndicator.textContent = localize('preview', 'PREVIEW'); } - if (content.progress) { - const progress = dom.append(this.element, $('.chat-welcome-view-progress')); - progress.appendChild(renderIcon(spinningLoading)); - - const progressLabel = dom.append(progress, $('span')); - progressLabel.textContent = content.progress; - } - const message = dom.append(this.element, $('.chat-welcome-view-message')); if (content.icon) { @@ -166,6 +159,14 @@ export class ChatViewWelcomePart extends Disposable { dom.append(message, messageResult.element); + if (content.progress) { + const progress = dom.append(this.element, $('.chat-welcome-view-progress')); + progress.appendChild(renderIcon(spinningLoading)); + + const progressLabel = dom.append(progress, $('span')); + progressLabel.textContent = content.progress; + } + if (content.tips) { const tips = dom.append(this.element, $('.chat-welcome-view-tips')); const tipsResult = this._register(renderer.render(content.tips)); diff --git a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts index d491e69b259..cca651b36c5 100644 --- a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts @@ -45,11 +45,9 @@ export namespace ChatContextKeys { signedIn: new RawContextKey('chatSetupSignedIn', false, { type: 'boolean', description: localize('chatSetupSignedIn', "True when chat setup is offered for a signed-in user.") }), entitled: new RawContextKey('chatSetupEntitled', false, { type: 'boolean', description: localize('chatSetupEntitled', "True when chat setup is offered for a signed-in, entitled user.") }), - triggering: new RawContextKey('chatSetupTriggered', false, { type: 'boolean', description: localize('chatSetupTriggered', "True when chat setup is triggered.") }), + triggered: new RawContextKey('chatSetupTriggered', false, { type: 'boolean', description: localize('chatSetupTriggered', "True when chat setup is triggered.") }), installing: new RawContextKey('chatSetupInstalling', false, { type: 'boolean', description: localize('chatSetupInstalling', "True when chat setup is installing chat.") }), signingIn: new RawContextKey('chatSetupSigningIn', false, { type: 'boolean', description: localize('chatSetupSigningIn', "True when chat setup is waiting for signing in.") }) }; - export const setupRunning = ContextKeyExpr.or(ChatSetup.triggering, ChatSetup.signingIn, ChatSetup.installing); - - export const shouldShowMovedViewWelcome = new RawContextKey('chatShouldShowMovedViewWelcome', false, { type: 'boolean', description: localize('chatShouldShowMovedViewWelcome', "True when the user should be shown the moved view welcome view.") }); + export const setupRunning = ContextKeyExpr.or(ChatSetup.triggered, ChatSetup.signingIn, ChatSetup.installing); } diff --git a/src/vs/workbench/contrib/chat/common/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/chatEditingService.ts index 36b074b0e8c..4cdc8f3fb38 100644 --- a/src/vs/workbench/contrib/chat/common/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/chatEditingService.ts @@ -42,8 +42,9 @@ export interface IChatEditingService { getSnapshotUri(requestId: string, uri: URI): URI | undefined; restoreSnapshot(requestId: string | undefined): Promise; + hasRelatedFilesProviders(): boolean; registerRelatedFilesProvider(handle: number, provider: IChatRelatedFilesProvider): IDisposable; - getRelatedFiles(chatSessionId: string, prompt: string, token: CancellationToken): Promise; + getRelatedFiles(chatSessionId: string, prompt: string, token: CancellationToken): Promise<{ group: string; files: IChatRelatedFile[] }[] | undefined>; } export interface IChatRequestDraft { @@ -51,25 +52,32 @@ export interface IChatRequestDraft { readonly files: readonly URI[]; } +export interface IChatRelatedFileProviderMetadata { + readonly description: string; +} + export interface IChatRelatedFile { readonly uri: URI; readonly description: string; } export interface IChatRelatedFilesProvider { + readonly description: string; provideRelatedFiles(chatRequest: IChatRequestDraft, token: CancellationToken): Promise; } +export interface WorkingSetDisplayMetadata { state: WorkingSetEntryState; description?: string } + export interface IChatEditingSession { readonly chatSessionId: string; - readonly onDidChange: Event; + readonly onDidChange: Event; readonly onDidDispose: Event; readonly state: IObservable; readonly entries: IObservable; readonly hiddenRequestIds: IObservable; - readonly workingSet: ResourceMap; + readonly workingSet: ResourceMap; readonly isVisible: boolean; - addFileToWorkingSet(uri: URI): void; + addFileToWorkingSet(uri: URI, description?: string, kind?: WorkingSetEntryState.Transient | WorkingSetEntryState.Suggested): void; show(): Promise; remove(...uris: URI[]): void; accept(...uris: URI[]): Promise; @@ -89,7 +97,13 @@ export const enum WorkingSetEntryState { Rejected, Transient, Attached, - Sent, + Sent, // TODO@joyceerhl remove this + Suggested, +} + +export const enum ChatEditingSessionChangeType { + WorkingSet, + Other, } export interface IModifiedFileEntry { diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index e87ec0d7fba..f3a9d57ac4c 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -36,7 +36,7 @@ export interface IBaseChatRequestVariableEntry { mimeType?: string; // TODO these represent different kinds, should be extracted to new interfaces with kind tags - kind?: unknown; + kind?: never; /** * True if the variable has a value vs being a reference to a variable */ @@ -49,12 +49,25 @@ export interface IBaseChatRequestVariableEntry { export interface IChatRequestImplicitVariableEntry extends Omit { readonly kind: 'implicit'; + readonly isDynamic: true; + readonly isFile: true; readonly value: URI | Location | undefined; readonly isSelection: boolean; enabled: boolean; } -export type IChatRequestVariableEntry = IChatRequestImplicitVariableEntry | IBaseChatRequestVariableEntry; +export interface ISymbolVariableEntry extends Omit { + readonly kind: 'symbol'; + readonly isDynamic: true; + readonly value: Location; +} + +export interface ICommandResultVariableEntry extends Omit { + readonly kind: 'command'; + readonly isDynamic: true; +} + +export type IChatRequestVariableEntry = IChatRequestImplicitVariableEntry | ISymbolVariableEntry | ICommandResultVariableEntry | IBaseChatRequestVariableEntry; export function isImplicitVariableEntry(obj: IChatRequestVariableEntry): obj is IChatRequestImplicitVariableEntry { return obj.kind === 'implicit'; diff --git a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts index e34a981703b..d5a8839dd11 100644 --- a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts @@ -14,16 +14,15 @@ import { Disposable, DisposableMap, IDisposable } from '../../../../base/common/ import { revive } from '../../../../base/common/marshalling.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { isLocation } from '../../../../editor/common/languages.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { Progress } from '../../../../platform/progress/common/progress.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { ChatAgentLocation, IChatAgent, IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from './chatAgents.js'; import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData, toChatHistoryContent, updateRanges } from './chatModel.js'; @@ -60,6 +59,7 @@ type ChatProviderInvokedEvent = { numCodeBlocks: number; isParticipantDetected: boolean; enableCommandDetection: boolean; + attachmentKinds: string[]; }; type ChatProviderInvokedClassification = { @@ -76,6 +76,7 @@ type ChatProviderInvokedClassification = { numCodeBlocks: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of code blocks in the response.' }; isParticipantDetected: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the participant was automatically detected.' }; enableCommandDetection: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether participation detection was disabled for this invocation.' }; + attachmentKinds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The types of variables/attachments that the user included with their query.' }; owner: 'roblourens'; comment: 'Provides insight into the performance of Chat agents.'; }; @@ -131,8 +132,6 @@ export class ChatService extends Disposable implements IChatService { @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService, @IChatVariablesService private readonly chatVariablesService: IChatVariablesService, @IChatAgentService private readonly chatAgentService: IChatAgentService, - @IWorkbenchAssignmentService workbenchAssignmentService: IWorkbenchAssignmentService, - @IContextKeyService contextKeyService: IContextKeyService, @IConfigurationService private readonly configurationService: IConfigurationService ) { super(); @@ -167,7 +166,7 @@ export class ChatService extends Disposable implements IChatService { private saveState(): void { const liveChats = Array.from(this._sessionModels.values()) - .filter(session => session.initialLocation === ChatAgentLocation.Panel) + .filter(session => session.initialLocation === ChatAgentLocation.Panel || session.initialLocation === ChatAgentLocation.EditingSession) .filter(session => session.getRequests().length > 0); const isEmptyWindow = !this.workspaceContextService.getWorkspace().folders.length; @@ -585,15 +584,16 @@ export class ChatService extends Disposable implements IChatService { totalTime: stopWatch.elapsed(), result: 'cancelled', requestType, - agent: agentPart?.agent.id ?? '', - agentExtensionId: agentPart?.agent.extensionId.value ?? '', + agent: detectedAgent?.id ?? agentPart?.agent.id ?? '', + agentExtensionId: detectedAgent?.extensionId.value ?? agentPart?.agent.extensionId.value ?? '', slashCommand: agentSlashCommandPart ? agentSlashCommandPart.command.name : commandPart?.slashCommand.command, chatSessionId: model.sessionId, location, citations: request?.response?.codeCitations.length ?? 0, numCodeBlocks: getCodeBlocks(request.response?.response.toString() ?? '').length, isParticipantDetected: !!detectedAgent, - enableCommandDetection + enableCommandDetection, + attachmentKinds: this.attachmentKindsForTelemetry(request.variableData) }); model.cancelRequest(request); @@ -720,15 +720,16 @@ export class ChatService extends Disposable implements IChatService { totalTime: rawResult.timings?.totalElapsed, result, requestType, - agent: agentPart?.agent.id ?? '', - agentExtensionId: agentPart?.agent.extensionId.value ?? '', + agent: detectedAgent?.id ?? agentPart?.agent.id ?? '', + agentExtensionId: detectedAgent?.extensionId.value ?? agentPart?.agent.extensionId.value ?? '', slashCommand: commandForTelemetry, chatSessionId: model.sessionId, enableCommandDetection, isParticipantDetected: !!detectedAgent, location, citations: request.response?.codeCitations.length ?? 0, - numCodeBlocks: getCodeBlocks(request.response?.response.toString() ?? '').length + numCodeBlocks: getCodeBlocks(request.response?.response.toString() ?? '').length, + attachmentKinds: this.attachmentKindsForTelemetry(request.variableData) }); model.setResponse(request, rawResult); completeResponseCreated(); @@ -754,15 +755,16 @@ export class ChatService extends Disposable implements IChatService { totalTime: undefined, result, requestType, - agent: agentPart?.agent.id ?? '', - agentExtensionId: agentPart?.agent.extensionId.value ?? '', + agent: detectedAgent?.id ?? agentPart?.agent.id ?? '', + agentExtensionId: detectedAgent?.extensionId.value ?? agentPart?.agent.extensionId.value ?? '', slashCommand: agentSlashCommandPart ? agentSlashCommandPart.command.name : commandPart?.slashCommand.command, chatSessionId: model.sessionId, location, citations: 0, numCodeBlocks: 0, enableCommandDetection, - isParticipantDetected: !!detectedAgent + isParticipantDetected: !!detectedAgent, + attachmentKinds: this.attachmentKindsForTelemetry(request.variableData) }); this.logService.error(`Error while handling chat request: ${toErrorMessage(err, true)}`); if (request) { @@ -786,6 +788,44 @@ export class ChatService extends Disposable implements IChatService { }; } + private attachmentKindsForTelemetry(variableData: IChatRequestVariableData): string[] { + // TODO this shows why attachments still have to be cleaned up somewhat + return variableData.variables.map(v => { + if (v.kind === 'implicit') { + return 'implicit'; + } else if (v.range) { + // 'range' is range within the prompt text + if (v.isTool) { + return 'toolInPrompt'; + } else if (v.isDynamic) { + return 'fileInPrompt'; + } else { + return 'variableInPrompt'; + } + } else if (v.kind === 'command') { + return 'command'; + } else if (v.kind === 'symbol') { + return 'symbol'; + } else if (v.isImage) { + return 'image'; + } else if (v.isDirectory) { + return 'directory'; + } else if (v.isTool) { + return 'tool'; + } else if (v.isDynamic) { + if (URI.isUri(v.value)) { + return 'file'; + } else if (isLocation(v.value)) { + return 'location'; + } else { + return 'otherAttachment'; + } + } else { + return 'variableAttachment'; + } + }); + } + private getHistoryEntriesFromModel(requests: IChatRequestModel[], sessionId: string, location: ChatAgentLocation, forAgentId: string): IChatAgentHistoryEntry[] { const history: IChatAgentHistoryEntry[] = []; for (const request of requests) { @@ -912,7 +952,7 @@ export class ChatService extends Disposable implements IChatService { } public hasSessions(): boolean { - return !!Object.values(this._persistedSessions); + return Object.values(this._persistedSessions).length > 0; } transferChatSession(transferredSessionData: IChatTransferredSessionData, toWorkspace: URI): void { diff --git a/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts b/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts index 16744cbaff4..7dd9973b03b 100644 --- a/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts +++ b/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts @@ -10,7 +10,6 @@ import { URI } from '../../../../base/common/uri.js'; import { Range } from '../../../../editor/common/core/range.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { EndOfLinePreference, ITextModel } from '../../../../editor/common/model.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { extractCodeblockUrisFromText, extractVulnerabilitiesFromText, IMarkdownVulnerability } from './annotations.js'; import { IChatRequestViewModel, IChatResponseViewModel, isResponseVM } from './chatViewModel.js'; @@ -28,18 +27,10 @@ interface CodeBlockEntry { readonly codemapperUri?: URI; } -type CodeBlockTextModel = { - readonly type: 'incomplete'; - readonly value: ITextModel; -} | { - readonly type: 'complete'; - readonly value: Promise>; -}; - export class CodeBlockModelCollection extends Disposable { private readonly _models = new Map>; vulns: readonly IMarkdownVulnerability[]; codemapperUri?: URI; }>(); @@ -53,7 +44,6 @@ export class CodeBlockModelCollection extends Disposable { constructor( @ILanguageService private readonly languageService: ILanguageService, - @IModelService private readonly modelService: IModelService, @ITextModelService private readonly textModelService: ITextModelService, ) { super(); @@ -70,7 +60,7 @@ export class CodeBlockModelCollection extends Disposable { return; } return { - model: entry.model.type === 'incomplete' ? Promise.resolve(entry.model.value) : entry.model.value.then(ref => ref.object.textEditorModel), + model: entry.model.then(ref => ref.object.textEditorModel), vulns: entry.vulns, codemapperUri: entry.codemapperUri }; @@ -82,10 +72,10 @@ export class CodeBlockModelCollection extends Disposable { return existing; } - const uri = this.getIncompleteModelUri(sessionId, chat, codeBlockIndex); - const model = this.modelService.createModel('', null, uri, true); + const uri = this.getCodeBlockUri(sessionId, chat, codeBlockIndex); + const model = this.textModelService.createModelReference(uri); this._models.set(this.getKey(sessionId, chat, codeBlockIndex), { - model: { type: 'incomplete', value: model }, + model: model, vulns: [], codemapperUri: undefined, }); @@ -98,7 +88,7 @@ export class CodeBlockModelCollection extends Disposable { this.delete(first); } - return { model: Promise.resolve(model), vulns: [], codemapperUri: undefined }; + return { model: model.then(x => x.object.textEditorModel), vulns: [], codemapperUri: undefined }; } private delete(key: string) { @@ -107,21 +97,13 @@ export class CodeBlockModelCollection extends Disposable { return; } - this.disposeModel(entry.model); + entry.model.then(ref => ref.object.dispose()); this._models.delete(key); } - private disposeModel(model: CodeBlockTextModel) { - if (model.type === 'complete') { - model.value.then(ref => ref.dispose()); - } else { - this.modelService.destroyModel(model.value.uri); - } - } - clear(): void { - this._models.forEach(async entry => this.disposeModel(entry.model)); + this._models.forEach(async entry => (await entry.model).dispose()); this._models.clear(); } @@ -146,15 +128,10 @@ export class CodeBlockModelCollection extends Disposable { markCodeBlockCompleted(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number): void { const entry = this._models.get(this.getKey(sessionId, chat, codeBlockIndex)); - if (!entry || entry.model.type === 'complete') { + if (!entry) { return; } - - this.disposeModel(entry.model); - - const uri = this.getCompletedModelUri(sessionId, chat, codeBlockIndex); - const newModel = this.textModelService.createModelReference(uri); - entry.model = { type: 'complete', value: newModel }; + // TODO: fill this in once we've implemented https://github.com/microsoft/vscode/issues/232538 } async update(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number, content: CodeBlockContent): Promise { @@ -222,15 +199,7 @@ export class CodeBlockModelCollection extends Disposable { return `${sessionId}/${chat.id}/${index}`; } - private getIncompleteModelUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, index: number): URI { - return URI.from({ - scheme: Schemas.inMemory, - authority: 'chat-code-block', - path: `/${sessionId}/${chat.id}/${index}` - }); - } - - private getCompletedModelUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, index: number): URI { + private getCodeBlockUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, index: number): URI { const metadata = this.getUriMetaData(chat); return URI.from({ scheme: Schemas.vscodeChatCodeBlock, diff --git a/src/vs/workbench/contrib/chat/test/browser/mockChatWidget.ts b/src/vs/workbench/contrib/chat/test/browser/mockChatWidget.ts index 3b5a9541d3e..cf64e8379ac 100644 --- a/src/vs/workbench/contrib/chat/test/browser/mockChatWidget.ts +++ b/src/vs/workbench/contrib/chat/test/browser/mockChatWidget.ts @@ -26,11 +26,7 @@ export class MockChatWidgetService implements IChatWidgetService { return undefined; } - getWidgetByLocation(location: ChatAgentLocation): IChatWidget[] { - return []; - } - - getAllWidgets(location: ChatAgentLocation): ReadonlyArray { + getWidgetsByLocations(location: ChatAgentLocation): ReadonlyArray { return []; } } diff --git a/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts b/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts index 7f84c87d2ab..8241f44ff19 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts @@ -361,7 +361,7 @@ export class SuggestEnabledInputWithHistory extends SuggestEnabledInput implemen @IConfigurationService configurationService: IConfigurationService ) { super(id, parent, suggestionProvider, ariaLabel, resourceHandle, suggestOptions, instantiationService, modelService, contextKeyService, languageFeaturesService, configurationService); - this.history = new HistoryNavigator(history, 100); + this.history = new HistoryNavigator(new Set(history), 100); } public addToHistory(): void { diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts b/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts index 7bac752e389..94578614d7b 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts @@ -5,6 +5,7 @@ import './media/review.css'; import * as dom from '../../../../base/browser/dom.js'; +import * as domStylesheets from '../../../../base/browser/domStylesheets.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, dispose, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; @@ -142,7 +143,7 @@ export class CommentThreadWidget extends ) as unknown as CommentThreadBody; this._register(this._body); this._setAriaLabel(); - this._styleElement = dom.createStyleSheet(this.container); + this._styleElement = domStylesheets.createStyleSheet(this.container); this._commentThreadContextValue = CommentContextKeys.commentThreadContext.bindTo(this._contextKeyService); diff --git a/src/vs/workbench/contrib/debug/browser/repl.ts b/src/vs/workbench/contrib/debug/browser/repl.ts index 533db0069c2..90e65246009 100644 --- a/src/vs/workbench/contrib/debug/browser/repl.ts +++ b/src/vs/workbench/contrib/debug/browser/repl.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../base/browser/dom.js'; +import * as domStylesheetsJs from '../../../../base/browser/domStylesheets.js'; import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; import { IActionViewItem } from '../../../../base/browser/ui/actionbar/actionbar.js'; import * as aria from '../../../../base/browser/ui/aria/aria.js'; @@ -708,7 +709,7 @@ export class Repl extends FilterViewPane implements IHistoryNavigationWidget { })); // Make sure to select the session if debugging is already active this.selectSession(); - this.styleElement = dom.createStyleSheet(this.container); + this.styleElement = domStylesheetsJs.createStyleSheet(this.container); this.onDidStyleChange(); } diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 3addd18766f..4db7fda24f2 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -8,7 +8,7 @@ import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { MenuRegistry, MenuId, registerAction2, Action2, IMenuItem, IAction2Options } from '../../../../platform/actions/common/actions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { ExtensionsLocalizedLabel, IExtensionManagementService, IExtensionGalleryService, PreferencesLocalizedLabel, EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, UseUnpkgResourceApi } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { ExtensionsLocalizedLabel, IExtensionManagementService, IExtensionGalleryService, PreferencesLocalizedLabel, EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, UseUnpkgResourceApi, InstallOperation } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { EnablementState, IExtensionManagementServerService, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService, extensionsConfigurationNodeBase } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from '../../../common/contributions.js'; @@ -77,6 +77,7 @@ import { CONTEXT_KEYBINDINGS_EDITOR } from '../../preferences/common/preferences import { ProgressLocation } from '../../../../platform/progress/common/progress.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IConfigurationMigrationRegistry, Extensions as ConfigurationMigrationExtensions } from '../../../common/configuration.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; // Singletons registerSingleton(IExtensionsWorkbenchService, ExtensionsWorkbenchService, InstantiationType.Eager /* Auto updates extensions */); @@ -491,7 +492,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi constructor( @IExtensionManagementServerService private readonly extensionManagementServerService: IExtensionManagementServerService, - @IExtensionGalleryService extensionGalleryService: IExtensionGalleryService, + @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, @IContextKeyService contextKeyService: IContextKeyService, @IViewsService private readonly viewsService: IViewsService, @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @@ -499,6 +500,10 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi @IInstantiationService private readonly instantiationService: IInstantiationService, @IDialogService private readonly dialogService: IDialogService, @ICommandService private readonly commandService: ICommandService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IProductService private readonly productService: IProductService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @INotificationService private readonly notificationService: INotificationService, ) { super(); const hasGalleryContext = CONTEXT_HAS_GALLERY.bindTo(contextKeyService); @@ -1582,6 +1587,53 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi run: async (accessor: ServicesAccessor, id: string) => accessor.get(IPreferencesService).openSettings({ jsonEditor: false, query: `@ext:${id}` }) }); + const downloadVSIX = async (extensionId: string, preRelease: boolean) => { + const result = await this.fileDialogService.showOpenDialog({ + title: localize('download title', "Select folder to download the VSIX"), + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: localize('download', "Download"), + }); + + if (!result?.[0]) { + return; + } + + const [galleryExtension] = await this.extensionGalleryService.getExtensions([{ id: extensionId, preRelease: true }], { compatible: true }, CancellationToken.None); + if (!galleryExtension) { + throw new Error(localize('not found', "Extension '{0}' not found.", extensionId)); + } + await this.extensionGalleryService.download(galleryExtension, this.uriIdentityService.extUri.joinPath(result[0], `${galleryExtension.identifier.id}-${galleryExtension.version}.vsix`), InstallOperation.None); + this.notificationService.info(localize('download.completed', "Successfully downloaded the VSIX")); + }; + + this.registerExtensionAction({ + id: 'workbench.extensions.action.download', + title: localize('download VSIX', "Download VSIX"), + menu: { + id: MenuId.ExtensionContext, + when: ContextKeyExpr.and(ContextKeyExpr.equals('extensionStatus', 'uninstalled'), ContextKeyExpr.has('isGalleryExtension')), + order: this.productService.quality === 'stable' ? 0 : 1 + }, + run: async (accessor: ServicesAccessor, extensionId: string) => { + downloadVSIX(extensionId, false); + } + }); + + this.registerExtensionAction({ + id: 'workbench.extensions.action.downloadPreRelease', + title: localize('download pre-release', "Download Pre-Release VSIX"), + menu: { + id: MenuId.ExtensionContext, + when: ContextKeyExpr.and(ContextKeyExpr.equals('extensionStatus', 'uninstalled'), ContextKeyExpr.has('isGalleryExtension'), ContextKeyExpr.has('extensionHasPreReleaseVersion')), + order: this.productService.quality === 'stable' ? 1 : 0 + }, + run: async (accessor: ServicesAccessor, extensionId: string) => { + downloadVSIX(extensionId, true); + } + }); + this.registerExtensionAction({ id: 'workbench.extensions.action.manageAccountPreferences', title: localize2('workbench.extensions.action.changeAccountPreference', "Account Preferences"), diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts index ccef08d493a..9fbe55ef7de 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts @@ -6,9 +6,9 @@ import { localize } from '../../../../nls.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { Event, Emitter } from '../../../../base/common/event.js'; -import { isCancellationError, getErrorMessage } from '../../../../base/common/errors.js'; +import { isCancellationError, getErrorMessage, CancellationError } from '../../../../base/common/errors.js'; import { createErrorWithActions } from '../../../../base/common/errorMessage.js'; -import { PagedModel, IPagedModel, IPager, DelayedPagedModel } from '../../../../base/common/paging.js'; +import { PagedModel, IPagedModel, DelayedPagedModel, IPager } from '../../../../base/common/paging.js'; import { SortOrder, IQueryOptions as IGalleryQueryOptions, SortBy as GallerySortBy, InstallExtensionInfo, ExtensionGalleryErrorCode, ExtensionGalleryError } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { IExtensionManagementServer, IExtensionManagementServerService, EnablementState, IWorkbenchExtensionManagementService, IWorkbenchExtensionEnablementService } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IExtensionRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js'; @@ -31,12 +31,12 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { ViewPane, IViewPaneOptions, ViewPaneShowActions } from '../../../browser/parts/views/viewPane.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { coalesce, distinct } from '../../../../base/common/arrays.js'; +import { coalesce, distinct, range } from '../../../../base/common/arrays.js'; import { alert } from '../../../../base/browser/ui/aria/aria.js'; import { IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { IAction, Action, Separator, ActionRunner } from '../../../../base/common/actions.js'; -import { ExtensionIdentifier, ExtensionIdentifierMap, ExtensionUntrustedWorkspaceSupportType, ExtensionVirtualWorkspaceSupportType, IExtensionDescription, isLanguagePackExtension } from '../../../../platform/extensions/common/extensions.js'; +import { ExtensionIdentifier, ExtensionIdentifierMap, ExtensionUntrustedWorkspaceSupportType, ExtensionVirtualWorkspaceSupportType, IExtensionDescription, IExtensionIdentifier, isLanguagePackExtension } from '../../../../platform/extensions/common/extensions.js'; import { CancelablePromise, createCancelablePromise, ThrottledDelayer } from '../../../../base/common/async.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { SeverityIcon } from '../../../../platform/severityIcon/browser/severityIcon.js'; @@ -387,7 +387,7 @@ export class ExtensionsListView extends ViewPane { result.push(...galleryResult); } - return this.getPagedModel(result); + return new PagedModel(result); } private async queryLocal(query: Query, options: IQueryOptions): Promise { @@ -773,49 +773,79 @@ export class ExtensionsListView extends ViewPane { const text = query.value; + if (!text) { + options.source = 'viewlet'; + const pager = await this.extensionsWorkbenchService.queryGallery(options, token); + return new PagedModel(pager); + } + if (/\bext:([^\s]+)\b/g.test(text)) { options.text = text; options.source = 'file-extension-tags'; - return this.extensionsWorkbenchService.queryGallery(options, token).then(pager => this.getPagedModel(pager)); + const pager = await this.extensionsWorkbenchService.queryGallery(options, token); + return new PagedModel(pager); } - let preferredResults: string[] = []; - if (text) { - options.text = text.substring(0, 350); - options.source = 'searchText'; - if (!hasUserDefinedSortOrder) { - const manifest = await this.extensionManagementService.getExtensionsControlManifest(); - const search = manifest.search; - if (Array.isArray(search)) { - for (const s of search) { - if (s.query && s.query.toLowerCase() === text.toLowerCase() && Array.isArray(s.preferredResults)) { - preferredResults = s.preferredResults; - break; - } + options.text = text.substring(0, 350); + options.source = 'searchText'; + + if (hasUserDefinedSortOrder || /\b(category|tag):([^\s]+)\b/gi.test(text) || /\bfeatured(\s+|\b|$)/gi.test(text)) { + const pager = await this.extensionsWorkbenchService.queryGallery(options, token); + return new PagedModel(pager); + } + + const [pager, preferredExtensions] = await Promise.all([ + this.extensionsWorkbenchService.queryGallery(options, token), + this.getPreferredExtensions(options.text.toLowerCase(), token).catch(() => []) + ]); + + return preferredExtensions.length ? new PreferredExtensionsPagedModel(preferredExtensions, pager) : new PagedModel(pager); + } + + private async getPreferredExtensions(searchText: string, token: CancellationToken): Promise { + const preferredExtensions = this.extensionsWorkbenchService.local.filter(e => !e.isBuiltin && (e.name.toLowerCase().indexOf(searchText) > -1 || e.displayName.toLowerCase().indexOf(searchText) > -1 || e.description.toLowerCase().indexOf(searchText) > -1)); + const preferredExtensionUUIDs = new Set(); + + if (preferredExtensions.length) { + // Update gallery data for preferred extensions if they are not yet fetched + const extesionsToFetch: IExtensionIdentifier[] = []; + for (const extension of preferredExtensions) { + if (extension.identifier.uuid) { + preferredExtensionUUIDs.add(extension.identifier.uuid); + } + if (!extension.gallery && extension.identifier.uuid) { + extesionsToFetch.push(extension.identifier); + } + } + if (extesionsToFetch.length) { + this.extensionsWorkbenchService.getExtensions(extesionsToFetch, CancellationToken.None).catch(e => null/*ignore error*/); + } + } + + const preferredResults: string[] = []; + try { + const manifest = await this.extensionManagementService.getExtensionsControlManifest(); + if (Array.isArray(manifest.search)) { + for (const s of manifest.search) { + if (s.query && s.query.toLowerCase() === searchText && Array.isArray(s.preferredResults)) { + preferredResults.push(...s.preferredResults); + break; } } } - } else { - options.source = 'viewlet'; - } - - const pager = await this.extensionsWorkbenchService.queryGallery(options, token); - - let positionToUpdate = 0; - for (const preferredResult of preferredResults) { - for (let j = positionToUpdate; j < pager.firstPage.length; j++) { - if (areSameExtensions(pager.firstPage[j].identifier, { id: preferredResult })) { - if (positionToUpdate !== j) { - const preferredExtension = pager.firstPage.splice(j, 1)[0]; - pager.firstPage.splice(positionToUpdate, 0, preferredExtension); - positionToUpdate++; + if (preferredResults.length) { + const result = await this.extensionsWorkbenchService.getExtensions(preferredResults.map(id => ({ id })), token); + for (const extension of result) { + if (extension.identifier.uuid && !preferredExtensionUUIDs.has(extension.identifier.uuid)) { + preferredExtensions.push(extension); } - break; } } + } catch (e) { + this.logService.warn('Failed to get preferred results from the extensions control manifest.', e); } - return this.getPagedModel(pager); + return preferredExtensions; } private sortExtensions(extensions: IExtension[], options: IQueryOptions): IExtension[] { @@ -1144,19 +1174,6 @@ export class ExtensionsListView extends ViewPane { this.notificationService.error(err); } - private getPagedModel(arg: IPager | IExtension[]): IPagedModel { - if (Array.isArray(arg)) { - return new PagedModel(arg); - } - const pager = { - total: arg.total, - pageSize: arg.pageSize, - firstPage: arg.firstPage, - getPage: (pageIndex: number, cancellationToken: CancellationToken) => arg.getPage(pageIndex, cancellationToken) - }; - return new PagedModel(pager); - } - override dispose(): void { super.dispose(); if (this.queryRequest) { @@ -1582,3 +1599,124 @@ export function getAriaLabelForExtension(extension: IExtension | null): string { const rating = extension?.rating ? localize('extension.arialabel.rating', "Rated {0} out of 5 stars by {1} users", extension.rating.toFixed(2), extension.ratingCount) : ''; return `${extension.displayName}, ${deprecated ? `${deprecated}, ` : ''}${extension.version}, ${publisher}, ${extension.description} ${rating ? `, ${rating}` : ''}`; } + +export class PreferredExtensionsPagedModel implements IPagedModel { + + private readonly resolved = new Map(); + private preferredGalleryExtensions = new Set(); + private resolvedGalleryExtensionsFromQuery: IExtension[] = []; + private readonly pages: Array<{ + promise: Promise | null; + cts: CancellationTokenSource | null; + promiseIndexes: Set; + }>; + + public readonly length: number; + + constructor( + private readonly preferredExtensions: IExtension[], + private readonly pager: IPager, + ) { + for (let i = 0; i < this.preferredExtensions.length; i++) { + this.resolved.set(i, this.preferredExtensions[i]); + } + + for (const e of preferredExtensions) { + if (e.identifier.uuid) { + this.preferredGalleryExtensions.add(e.identifier.uuid); + } + } + + // expected that all preferred gallery extensions will be part of the query results + this.length = (preferredExtensions.length - this.preferredGalleryExtensions.size) + this.pager.total; + + const totalPages = Math.ceil(this.pager.total / this.pager.pageSize); + this.populateResolvedExtensions(0, this.pager.firstPage); + this.pages = range(totalPages - 1).map(() => ({ + promise: null, + cts: null, + promiseIndexes: new Set(), + })); + } + + isResolved(index: number): boolean { + return this.resolved.has(index); + } + + get(index: number): IExtension { + return this.resolved.get(index)!; + } + + async resolve(index: number, cancellationToken: CancellationToken): Promise { + if (cancellationToken.isCancellationRequested) { + throw new CancellationError(); + } + + if (this.isResolved(index)) { + return this.get(index); + } + + const indexInPagedModel = index - this.preferredExtensions.length + this.resolvedGalleryExtensionsFromQuery.length; + const pageIndex = Math.floor(indexInPagedModel / this.pager.pageSize); + const page = this.pages[pageIndex]; + + if (!page.promise) { + page.cts = new CancellationTokenSource(); + page.promise = this.pager.getPage(pageIndex, page.cts.token) + .then(extensions => this.populateResolvedExtensions(pageIndex, extensions)) + .catch(e => { page.promise = null; throw e; }) + .finally(() => page.cts = null); + } + + const listener = cancellationToken.onCancellationRequested(() => { + if (!page.cts) { + return; + } + page.promiseIndexes.delete(index); + if (page.promiseIndexes.size === 0) { + page.cts.cancel(); + } + }); + + page.promiseIndexes.add(index); + + try { + await page.promise; + } finally { + listener.dispose(); + } + + return this.get(index); + } + + private populateResolvedExtensions(pageIndex: number, extensions: IExtension[]): void { + let adjustIndexOfNextPagesBy = 0; + const pageStartIndex = pageIndex * this.pager.pageSize; + for (let i = 0; i < extensions.length; i++) { + const e = extensions[i]; + if (e.gallery?.identifier.uuid && this.preferredGalleryExtensions.has(e.gallery.identifier.uuid)) { + this.resolvedGalleryExtensionsFromQuery.push(e); + adjustIndexOfNextPagesBy++; + } else { + this.resolved.set(this.preferredExtensions.length - this.resolvedGalleryExtensionsFromQuery.length + pageStartIndex + i, e); + } + } + // If this page has preferred gallery extensions, then adjust the index of the next pages + // by the number of preferred gallery extensions found in this page. Because these preferred extensions + // are already in the resolved list and since we did not add them now, we need to adjust the indices of the next pages. + // Skip first page as the preferred extensions are always in the first page + if (pageIndex !== 0 && adjustIndexOfNextPagesBy) { + const nextPageStartIndex = (pageIndex + 1) * this.pager.pageSize; + const indices = [...this.resolved.keys()].sort(); + for (const index of indices) { + if (index >= nextPageStartIndex) { + const e = this.resolved.get(index); + if (e) { + this.resolved.delete(index); + this.resolved.set(index - adjustIndexOfNextPagesBy, e); + } + } + } + } + } +} diff --git a/src/vs/workbench/contrib/extensions/test/electron-sandbox/extensionsViews.test.ts b/src/vs/workbench/contrib/extensions/test/electron-sandbox/extensionsViews.test.ts index 6507d4cb008..0e995dbf153 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-sandbox/extensionsViews.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-sandbox/extensionsViews.test.ts @@ -11,7 +11,7 @@ import { IExtensionsWorkbenchService } from '../../common/extensions.js'; import { ExtensionsWorkbenchService } from '../../browser/extensionsWorkbenchService.js'; import { IExtensionManagementService, IExtensionGalleryService, ILocalExtension, IGalleryExtension, IQueryOptions, - getTargetPlatform, IExtensionInfo, SortBy + getTargetPlatform, SortBy } from '../../../../../platform/extensionManagement/common/extensionManagement.js'; import { IWorkbenchExtensionEnablementService, EnablementState, IExtensionManagementServerService, IExtensionManagementServer, IProfileAwareExtensionManagementService, IWorkbenchExtensionManagementService } from '../../../../services/extensionManagement/common/extensionManagement.js'; import { IExtensionRecommendationsService, ExtensionRecommendationReason } from '../../../../services/extensionRecommendations/common/extensionRecommendations.js'; @@ -70,7 +70,8 @@ suite('ExtensionsViews Tests', () => { const builtInTheme = aLocalExtension('my-theme', { categories: ['Themes'], contributes: { themes: ['my-theme'] } }, { type: ExtensionType.System, installedTimestamp: 222 }); const builtInBasic = aLocalExtension('my-lang', { categories: ['Programming Languages'], contributes: { grammars: [{ language: 'my-language' }] } }, { type: ExtensionType.System, installedTimestamp: 666666 }); - const galleryEnabledLanguage = aGalleryExtension(localEnabledLanguage.manifest.name, { ...localEnabledLanguage.manifest, version: '1.0.1', identifier: localDisabledLanguage.identifier }); + let queryPage = aPage([]); + const galleryExtensions: IGalleryExtension[] = []; const workspaceRecommendationA = aGalleryExtension('workspace-recommendation-A'); const workspaceRecommendationB = aGalleryExtension('workspace-recommendation-B'); @@ -169,10 +170,27 @@ suite('ExtensionsViews Tests', () => { instantiationService.stubPromise(IExtensionManagementService, 'getInstalled', [localEnabledTheme, localEnabledLanguage, localRandom, localDisabledTheme, localDisabledLanguage, builtInTheme, builtInBasic]); instantiationService.stubPromise(IExtensionManagementService, 'getExtensgetExtensionsControlManifestionsReport', {}); - instantiationService.stub(IExtensionGalleryService, 'isEnabled', true); - instantiationService.stubPromise(IExtensionGalleryService, 'query', aPage(galleryEnabledLanguage)); - instantiationService.stubPromise(IExtensionGalleryService, 'getCompatibleExtension', galleryEnabledLanguage); - instantiationService.stubPromise(IExtensionGalleryService, 'getExtensions', [galleryEnabledLanguage]); + + instantiationService.stub(IExtensionGalleryService, >{ + query: async () => { + return queryPage; + }, + getCompatibleExtension: async (gallery) => { + return gallery; + }, + getExtensions: async (infos) => { + const result: IGalleryExtension[] = []; + for (const info of infos) { + const extension = galleryExtensions.find(e => e.identifier.id === info.id); + if (extension) { + result.push(extension); + } + } + return result; + }, + isEnabled: () => true, + isExtensionCompatible: async () => true, + }); instantiationService.stub(IViewDescriptorService, { getViewLocationById(): ViewContainerLocation { @@ -199,6 +217,17 @@ suite('ExtensionsViews Tests', () => { instantiationService.stub(IUpdateService, { onStateChange: Event.None, state: State.Uninitialized }); instantiationService.set(IExtensionsWorkbenchService, disposableStore.add(instantiationService.createInstance(ExtensionsWorkbenchService))); testableView = disposableStore.add(instantiationService.createInstance(ExtensionsListView, {}, { id: '', title: '' })); + queryPage = aPage([]); + + galleryExtensions.splice(0, galleryExtensions.length, ...[ + workspaceRecommendationA, + workspaceRecommendationB, + configBasedRecommendationA, + configBasedRecommendationB, + fileBasedRecommendationA, + fileBasedRecommendationB, + otherRecommendationA + ]); }); test('Test query types', () => { @@ -217,36 +246,35 @@ suite('ExtensionsViews Tests', () => { assert.strictEqual(ExtensionsListView.isLocalExtensionsQuery('@updates searchText'), true); }); - test('Test empty query equates to sort by install count', () => { + test('Test empty query equates to sort by install count', async () => { const target = instantiationService.stubPromise(IExtensionGalleryService, 'query', aPage()); - return testableView.show('').then(() => { - assert.ok(target.calledOnce); - const options: IQueryOptions = target.args[0][0]; - assert.strictEqual(options.sortBy, SortBy.InstallCount); - }); + await testableView.show(''); + assert.ok(target.calledOnce); + const options: IQueryOptions = target.args[0][0]; + assert.strictEqual(options.sortBy, SortBy.InstallCount); }); - test('Test non empty query without sort doesnt use sortBy', () => { + test('Test non empty query without sort doesnt use sortBy', async () => { const target = instantiationService.stubPromise(IExtensionGalleryService, 'query', aPage()); - return testableView.show('some extension').then(() => { - assert.ok(target.calledOnce); - const options: IQueryOptions = target.args[0][0]; - assert.strictEqual(options.sortBy, undefined); - }); + await testableView.show('some extension'); + assert.ok(target.calledOnce); + const options: IQueryOptions = target.args[0][0]; + assert.strictEqual(options.sortBy, undefined); }); - test('Test query with sort uses sortBy', () => { + test('Test query with sort uses sortBy', async () => { const target = instantiationService.stubPromise(IExtensionGalleryService, 'query', aPage()); - return testableView.show('some extension @sort:rating').then(() => { - assert.ok(target.calledOnce); - const options: IQueryOptions = target.args[0][0]; - assert.strictEqual(options.sortBy, SortBy.WeightedRating); - }); + await testableView.show('some extension @sort:rating'); + assert.ok(target.calledOnce); + const options: IQueryOptions = target.args[0][0]; + assert.strictEqual(options.sortBy, SortBy.WeightedRating); }); test('Test default view actions required sorting', async () => { + queryPage = aPage([aGalleryExtension(localEnabledLanguage.manifest.name, { ...localEnabledLanguage.manifest, version: '1.0.1', identifier: localDisabledLanguage.identifier })]); + const workbenchService = instantiationService.get(IExtensionsWorkbenchService); - const extension = (await workbenchService.queryLocal()).find(ex => ex.identifier === localEnabledLanguage.identifier); + const extension = (await workbenchService.queryLocal()).find(ex => ex.identifier.id === localEnabledLanguage.identifier.id); await new Promise(c => { const disposable = workbenchService.onChange(() => { @@ -397,42 +425,32 @@ suite('ExtensionsViews Tests', () => { workspaceRecommendationB, configBasedRecommendationA, ]; - const target = instantiationService.stubPromise(IExtensionGalleryService, 'getExtensions', workspaceRecommendedExtensions); return testableView.show('@recommended:workspace').then(result => { - const extensionInfos: IExtensionInfo[] = target.args[0][0]; - assert.strictEqual(extensionInfos.length, workspaceRecommendedExtensions.length); assert.strictEqual(result.length, workspaceRecommendedExtensions.length); for (let i = 0; i < workspaceRecommendedExtensions.length; i++) { - assert.strictEqual(extensionInfos[i].id, workspaceRecommendedExtensions[i].identifier.id); assert.strictEqual(result.get(i).identifier.id, workspaceRecommendedExtensions[i].identifier.id); } }); }); - test('Test @recommended query', () => { + test('Test @recommended query', async () => { const allRecommendedExtensions = [ fileBasedRecommendationA, fileBasedRecommendationB, configBasedRecommendationB, otherRecommendationA ]; - const target = instantiationService.stubPromise(IExtensionGalleryService, 'getExtensions', allRecommendedExtensions); - return testableView.show('@recommended').then(result => { - const extensionInfos: IExtensionInfo[] = target.args[0][0]; - - assert.strictEqual(extensionInfos.length, allRecommendedExtensions.length); - assert.strictEqual(result.length, allRecommendedExtensions.length); - for (let i = 0; i < allRecommendedExtensions.length; i++) { - assert.strictEqual(extensionInfos[i].id, allRecommendedExtensions[i].identifier.id); - assert.strictEqual(result.get(i).identifier.id, allRecommendedExtensions[i].identifier.id); - } - }); + const result = await testableView.show('@recommended'); + assert.strictEqual(result.length, allRecommendedExtensions.length); + for (let i = 0; i < allRecommendedExtensions.length; i++) { + assert.strictEqual(result.get(i).identifier.id, allRecommendedExtensions[i].identifier.id); + } }); - test('Test @recommended:all query', () => { + test('Test @recommended:all query', async () => { const allRecommendedExtensions = [ workspaceRecommendationA, workspaceRecommendationB, @@ -442,103 +460,80 @@ suite('ExtensionsViews Tests', () => { configBasedRecommendationB, otherRecommendationA, ]; - const target = instantiationService.stubPromise(IExtensionGalleryService, 'getExtensions', allRecommendedExtensions); - return testableView.show('@recommended:all').then(result => { - const extensionInfos: IExtensionInfo[] = target.args[0][0]; - - assert.strictEqual(extensionInfos.length, allRecommendedExtensions.length); - assert.strictEqual(result.length, allRecommendedExtensions.length); - for (let i = 0; i < allRecommendedExtensions.length; i++) { - assert.strictEqual(extensionInfos[i].id, allRecommendedExtensions[i].identifier.id); - assert.strictEqual(result.get(i).identifier.id, allRecommendedExtensions[i].identifier.id); - } - }); + const result = await testableView.show('@recommended:all'); + assert.strictEqual(result.length, allRecommendedExtensions.length); + for (let i = 0; i < allRecommendedExtensions.length; i++) { + assert.strictEqual(result.get(i).identifier.id, allRecommendedExtensions[i].identifier.id); + } }); - test('Test search', () => { - const searchText = 'search-me'; + test('Test search', async () => { const results = [ fileBasedRecommendationA, workspaceRecommendationA, otherRecommendationA, workspaceRecommendationB ]; - const queryTarget = instantiationService.stubPromise(IExtensionGalleryService, 'query', aPage(...results)); - return testableView.show('search-me').then(result => { - const options: IQueryOptions = queryTarget.args[0][0]; - - assert.ok(queryTarget.calledOnce); - assert.strictEqual(options.text, searchText); - assert.strictEqual(result.length, results.length); - for (let i = 0; i < results.length; i++) { - assert.strictEqual(result.get(i).identifier.id, results[i].identifier.id); - } - }); + queryPage = aPage(results); + const result = await testableView.show('search-me'); + assert.strictEqual(result.length, results.length); + for (let i = 0; i < results.length; i++) { + assert.strictEqual(result.get(i).identifier.id, results[i].identifier.id); + } }); - test('Test preferred search experiment', () => { - const searchText = 'search-me'; - const actual = [ + test('Test preferred search experiment', async () => { + queryPage = aPage([ fileBasedRecommendationA, workspaceRecommendationA, otherRecommendationA, workspaceRecommendationB - ]; + ], 5); + const notInFirstPage = aGalleryExtension('not-in-first-page'); + galleryExtensions.push(notInFirstPage); const expected = [ workspaceRecommendationA, + notInFirstPage, workspaceRecommendationB, fileBasedRecommendationA, - otherRecommendationA + otherRecommendationA, ]; - const queryTarget = instantiationService.stubPromise(IExtensionGalleryService, 'query', aPage(...actual)); - const experimentTarget = instantiationService.stubPromise(IWorkbenchExtensionManagementService, 'getExtensionsControlManifest', { + instantiationService.stubPromise(IWorkbenchExtensionManagementService, 'getExtensionsControlManifest', { malicious: [], deprecated: {}, search: [{ query: 'search-me', preferredResults: [ workspaceRecommendationA.identifier.id, - 'something-that-wasnt-in-first-page', + notInFirstPage.identifier.id, workspaceRecommendationB.identifier.id ] }] }); - return testableView.show('search-me').then(result => { - const options: IQueryOptions = queryTarget.args[0][0]; - - assert.ok(experimentTarget.calledTwice); - assert.ok(queryTarget.calledOnce); - assert.strictEqual(options.text, searchText); - assert.strictEqual(result.length, expected.length); - for (let i = 0; i < expected.length; i++) { - assert.strictEqual(result.get(i).identifier.id, expected[i].identifier.id); - } - }); + const testObject = disposableStore.add(instantiationService.createInstance(ExtensionsListView, {}, { id: '', title: '' })); + const result = await testObject.show('search-me'); + assert.strictEqual(result.length, expected.length); + for (let i = 0; i < expected.length; i++) { + assert.strictEqual(result.get(i).identifier.id, expected[i].identifier.id); + } }); - test('Skip preferred search experiment when user defines sort order', () => { - const searchText = 'search-me'; + test('Skip preferred search experiment when user defines sort order', async () => { const realResults = [ fileBasedRecommendationA, workspaceRecommendationA, otherRecommendationA, workspaceRecommendationB ]; + queryPage = aPage(realResults); - const queryTarget = instantiationService.stubPromise(IExtensionGalleryService, 'query', aPage(...realResults)); - - return testableView.show('search-me @sort:installs').then(result => { - const options: IQueryOptions = queryTarget.args[0][0]; - - assert.ok(queryTarget.calledOnce); - assert.strictEqual(options.text, searchText); - assert.strictEqual(result.length, realResults.length); - for (let i = 0; i < realResults.length; i++) { - assert.strictEqual(result.get(i).identifier.id, realResults[i].identifier.id); - } - }); + const result = await testableView.show('search-me @sort:installs'); + assert.strictEqual(result.length, realResults.length); + for (let i = 0; i < realResults.length; i++) { + assert.strictEqual(result.get(i).identifier.id, realResults[i].identifier.id); + } }); function aLocalExtension(name: string = 'someext', manifest: any = {}, properties: any = {}): ILocalExtension { @@ -564,8 +559,8 @@ suite('ExtensionsViews Tests', () => { return galleryExtension; } - function aPage(...objects: T[]): IPager { - return { firstPage: objects, total: objects.length, pageSize: objects.length, getPage: () => null! }; + function aPage(objects: IGalleryExtension[] = [], total?: number): IPager { + return { firstPage: objects, total: total ?? objects.length, pageSize: objects.length, getPage: () => null! }; } }); diff --git a/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts b/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts index d7d157109fa..9da55d34ce3 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts @@ -154,7 +154,7 @@ const copyRelativePathCommand = { title: nls.localize('copyRelativePath', "Copy Relative Path") }; -export const revealInsideBarCommand = { +export const revealInSideBarCommand = { id: REVEAL_IN_EXPLORER_COMMAND_ID, title: nls.localize('revealInSideBar', "Reveal in Explorer View") }; @@ -162,7 +162,7 @@ export const revealInsideBarCommand = { // Editor Title Context Menu appendEditorTitleContextMenuItem(COPY_PATH_COMMAND_ID, copyPathCommand.title, ResourceContextKey.IsFileSystemResource, '1_cutcopypaste', true); appendEditorTitleContextMenuItem(COPY_RELATIVE_PATH_COMMAND_ID, copyRelativePathCommand.title, ResourceContextKey.IsFileSystemResource, '1_cutcopypaste', true); -appendEditorTitleContextMenuItem(revealInsideBarCommand.id, revealInsideBarCommand.title, ResourceContextKey.IsFileSystemResource, '2_files', false, 1); +appendEditorTitleContextMenuItem(revealInSideBarCommand.id, revealInSideBarCommand.title, ResourceContextKey.IsFileSystemResource, '2_files', false, 1); export function appendEditorTitleContextMenuItem(id: string, title: string, when: ContextKeyExpression | undefined, group: string, supportsMultiSelect: boolean, order?: number): void { const precondition = supportsMultiSelect !== true ? MultipleEditorsSelectedInGroupContext.negate() : undefined; @@ -776,7 +776,7 @@ MenuRegistry.appendMenuItem(MenuId.ChatAttachmentsContext, { MenuRegistry.appendMenuItem(MenuId.ChatAttachmentsContext, { group: 'navigation', order: 20, - command: revealInsideBarCommand, + command: revealInSideBarCommand, when: ResourceContextKey.IsFileSystemResource }); @@ -807,7 +807,7 @@ for (const menuId of [MenuId.ChatInlineResourceAnchorContext, MenuId.ChatInputRe MenuRegistry.appendMenuItem(menuId, { group: 'navigation', order: 20, - command: revealInsideBarCommand, + command: revealInSideBarCommand, when: ResourceContextKey.IsFileSystemResource }); diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index bcad49acc19..b17d1a20cee 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -1040,7 +1040,7 @@ export class FilesRenderer implements ICompressibleTreeRenderer .chat-execute-toolbar .chat-modelPicker-item { + min-width: 40px; + max-width: 132px; +} /* status */ @@ -135,8 +143,7 @@ display: flex; justify-content: space-between; align-items: center; - padding-left: 8px; - padding-right: 8px; + padding-right: 16px; } .monaco-workbench .inline-chat > .status { diff --git a/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts b/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts index ff09fc5bc7b..249ee28a2f5 100644 --- a/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts +++ b/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts @@ -752,13 +752,8 @@ registerAction2(class extends Action2 { category: interactiveWindowCategory, menu: { id: MenuId.CommandPalette, - when: InteractiveWindowOpen, + when: InteractiveWindowOpen }, - keybinding: { - when: ContextKeyExpr.and(IS_COMPOSITE_NOTEBOOK, NOTEBOOK_EDITOR_FOCUSED), - weight: KeybindingWeight.WorkbenchContrib + 5, - primary: KeyMod.CtrlCmd | KeyCode.DownArrow - } }); } diff --git a/src/vs/workbench/contrib/interactive/browser/interactiveEditor.ts b/src/vs/workbench/contrib/interactive/browser/interactiveEditor.ts index aa4b1270ab7..669b094d675 100644 --- a/src/vs/workbench/contrib/interactive/browser/interactiveEditor.ts +++ b/src/vs/workbench/contrib/interactive/browser/interactiveEditor.ts @@ -5,6 +5,7 @@ import './media/interactive.css'; import * as DOM from '../../../../base/browser/dom.js'; +import * as domStylesheets from '../../../../base/browser/domStylesheets.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; @@ -222,7 +223,7 @@ export class InteractiveEditor extends EditorPane implements IEditorPaneWithScro } private _createLayoutStyles(): void { - this._styleElement = DOM.createStyleSheet(this._rootElement); + this._styleElement = domStylesheets.createStyleSheet(this._rootElement); const styleSheets: string[] = []; const { diff --git a/src/vs/workbench/contrib/issue/browser/baseIssueReporterService.ts b/src/vs/workbench/contrib/issue/browser/baseIssueReporterService.ts index 26d796b87f3..4e0b80d97f8 100644 --- a/src/vs/workbench/contrib/issue/browser/baseIssueReporterService.ts +++ b/src/vs/workbench/contrib/issue/browser/baseIssueReporterService.ts @@ -2,7 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, createStyleSheet, isHTMLInputElement, isHTMLTextAreaElement, reset, windowOpenNoOpener } from '../../../../base/browser/dom.js'; +import { $, isHTMLInputElement, isHTMLTextAreaElement, reset, windowOpenNoOpener } from '../../../../base/browser/dom.js'; +import { createStyleSheet } from '../../../../base/browser/domStylesheets.js'; import { Button, unthemedButtonStyles } from '../../../../base/browser/ui/button/button.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { mainWindow } from '../../../../base/browser/window.js'; diff --git a/src/vs/workbench/contrib/issue/browser/issueFormService.ts b/src/vs/workbench/contrib/issue/browser/issueFormService.ts index f2e9cad950f..c2449a6a102 100644 --- a/src/vs/workbench/contrib/issue/browser/issueFormService.ts +++ b/src/vs/workbench/contrib/issue/browser/issueFormService.ts @@ -111,8 +111,8 @@ export class IssueFormService implements IIssueFormService { const actions = menu.getActions({ renderShortTitle: true }).flatMap(entry => entry[1]); for (const action of actions) { try { - if (action.item && 'source' in action.item && action.item.source?.id === extensionId) { - this.extensionIdentifierSet.add(extensionId); + if (action.item && 'source' in action.item && action.item.source?.id.toLowerCase() === extensionId.toLowerCase()) { + this.extensionIdentifierSet.add(extensionId.toLowerCase()); await action.run(); } } catch (error) { diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/conflictActions.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/conflictActions.ts index 8a1cd72be34..0a0c485c598 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/conflictActions.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/conflictActions.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, createStyleSheet, h, isInShadowDOM, reset } from '../../../../../base/browser/dom.js'; +import { $, h, isInShadowDOM, reset } from '../../../../../base/browser/dom.js'; +import { createStyleSheet } from '../../../../../base/browser/domStylesheets.js'; import { renderLabelWithIcons } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { hash } from '../../../../../base/common/hash.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookCellDecorators.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookCellDecorators.ts new file mode 100644 index 00000000000..fd279db29ed --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookCellDecorators.ts @@ -0,0 +1,439 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../../base/common/resources.js'; +import { Disposable, DisposableStore, dispose, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived } from '../../../../../base/common/observable.js'; +import { IChatEditingService, ChatEditingSessionState } from '../../../chat/common/chatEditingService.js'; +import { NotebookTextModel } from '../../common/model/notebookTextModel.js'; +import { INotebookEditor } from '../notebookBrowser.js'; +import { ThrottledDelayer } from '../../../../../base/common/async.js'; +import { CellDiffInfo } from '../diff/notebookDiffViewModel.js'; +import { CellKind } from '../../common/notebookCommon.js'; +import { ICodeEditor, IViewZone } from '../../../../../editor/browser/editorBrowser.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { EditorOption } from '../../../../../editor/common/config/editorOptions.js'; +import { themeColorFromId } from '../../../../../base/common/themables.js'; +import { RenderOptions, LineSource, renderLines } from '../../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; +import { diffAddDecoration, diffWholeLineAddDecoration, diffDeleteDecoration } from '../../../../../editor/browser/widget/diffEditor/registrations.contribution.js'; +import { IDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js'; +import { ITextModel, TrackedRangeStickiness, MinimapPosition, IModelDeltaDecoration, OverviewRulerLane } from '../../../../../editor/common/model.js'; +import { ModelDecorationOptions } from '../../../../../editor/common/model/textModel.js'; +import { InlineDecoration, InlineDecorationType } from '../../../../../editor/common/viewModel.js'; +import { overviewRulerModifiedForeground, minimapGutterModifiedBackground, overviewRulerAddedForeground, minimapGutterAddedBackground, overviewRulerDeletedForeground, minimapGutterDeletedBackground } from '../../../scm/browser/dirtydiffDecorator.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { NotebookCellTextModel } from '../../common/model/notebookCellTextModel.js'; +import { tokenizeToString } from '../../../../../editor/common/languages/textToHtmlTokenizer.js'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { createTrustedTypesPolicy } from '../../../../../base/browser/trustedTypes.js'; +import { splitLines } from '../../../../../base/common/strings.js'; +import { DefaultLineHeight } from '../diff/diffElementViewModel.js'; +import { INotebookOriginalCellModelFactory } from './notebookOriginalCellModelFactory.js'; + + +export class NotebookCellDiffDecorator extends DisposableStore { + private readonly _decorations = this.editor.createDecorationsCollection(); + private _viewZones: string[] = []; + private readonly throttledDecorator = new ThrottledDelayer(100); + + constructor( + public readonly editor: ICodeEditor, + private readonly originalCellValue: string, + private readonly cellKind: CellKind, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, + @INotebookOriginalCellModelFactory private readonly originalCellModelFactory: INotebookOriginalCellModelFactory, + + ) { + super(); + this.add(this.editor.onDidChangeModel(() => this.update())); + this.add(this.editor.onDidChangeModelContent(() => this.update())); + this.add(this.editor.onDidChangeConfiguration((e) => { + if (e.hasChanged(EditorOption.fontInfo) || e.hasChanged(EditorOption.lineHeight)) { + this.update(); + } + })); + + const shouldBeReadOnly = derived(this, r => { + const value = this._chatEditingService.currentEditingSessionObs.read(r); + if (!value || value.state.read(r) !== ChatEditingSessionState.StreamingEdits) { + return false; + } + return value.entries.read(r).some(e => isEqual(e.modifiedURI, this.editor.getModel()?.uri)); + }); + + + let actualReadonly: boolean | undefined; + let actualDeco: 'off' | 'editable' | 'on' | undefined; + + this.add(autorun(r => { + const value = shouldBeReadOnly.read(r); + if (value) { + actualReadonly ??= this.editor.getOption(EditorOption.readOnly); + actualDeco ??= this.editor.getOption(EditorOption.renderValidationDecorations); + + this.editor.updateOptions({ + readOnly: true, + renderValidationDecorations: 'off' + }); + } else { + if (actualReadonly !== undefined && actualDeco !== undefined) { + this.editor.updateOptions({ + readOnly: actualReadonly, + renderValidationDecorations: actualDeco + }); + actualReadonly = undefined; + actualDeco = undefined; + } + } + })); + this.update(); + } + + override dispose(): void { + this._clearRendering(); + super.dispose(); + } + + public update(): void { + this.throttledDecorator.trigger(() => this._updateImpl()); + } + + private async _updateImpl() { + if (this.isDisposed) { + return; + } + if (this.editor.getOption(EditorOption.inDiffEditor)) { + this._clearRendering(); + return; + } + const model = this.editor.getModel(); + if (!model) { + this._clearRendering(); + return; + } + + const originalModel = this.getOrCreateOriginalModel(); + if (!originalModel) { + this._clearRendering(); + return; + } + const version = model.getVersionId(); + const diff = await this._editorWorkerService.computeDiff( + originalModel.uri, + model.uri, + { computeMoves: true, ignoreTrimWhitespace: false, maxComputationTimeMs: Number.MAX_SAFE_INTEGER }, + 'advanced' + ); + + if (this.isDisposed) { + return; + } + + if (diff && originalModel && model === this.editor.getModel() && this.editor.getModel()?.getVersionId() === version) { + this._updateWithDiff(originalModel, diff); + } else { + this._clearRendering(); + } + } + + private _clearRendering() { + this.editor.changeViewZones((viewZoneChangeAccessor) => { + for (const id of this._viewZones) { + viewZoneChangeAccessor.removeZone(id); + } + }); + this._viewZones = []; + this._decorations.clear(); + } + + private _originalModel?: ITextModel; + private getOrCreateOriginalModel() { + if (!this._originalModel) { + const model = this.editor.getModel(); + if (!model) { + return; + } + this._originalModel = this.add(this.originalCellModelFactory.getOrCreate(model.uri, this.originalCellValue, model.getLanguageId(), this.cellKind)).object; + } + return this._originalModel; + } + + private _updateWithDiff(originalModel: ITextModel | undefined, diff: IDocumentDiff): void { + const chatDiffAddDecoration = ModelDecorationOptions.createDynamic({ + ...diffAddDecoration, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges + }); + const chatDiffWholeLineAddDecoration = ModelDecorationOptions.createDynamic({ + ...diffWholeLineAddDecoration, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + }); + const createOverviewDecoration = (overviewRulerColor: string, minimapColor: string) => { + return ModelDecorationOptions.createDynamic({ + description: 'chat-editing-decoration', + overviewRuler: { color: themeColorFromId(overviewRulerColor), position: OverviewRulerLane.Left }, + minimap: { color: themeColorFromId(minimapColor), position: MinimapPosition.Gutter }, + }); + }; + const modifiedDecoration = createOverviewDecoration(overviewRulerModifiedForeground, minimapGutterModifiedBackground); + const addedDecoration = createOverviewDecoration(overviewRulerAddedForeground, minimapGutterAddedBackground); + const deletedDecoration = createOverviewDecoration(overviewRulerDeletedForeground, minimapGutterDeletedBackground); + + this.editor.changeViewZones((viewZoneChangeAccessor) => { + for (const id of this._viewZones) { + viewZoneChangeAccessor.removeZone(id); + } + this._viewZones = []; + const modifiedDecorations: IModelDeltaDecoration[] = []; + const mightContainNonBasicASCII = originalModel?.mightContainNonBasicASCII(); + const mightContainRTL = originalModel?.mightContainRTL(); + const renderOptions = RenderOptions.fromEditor(this.editor); + + for (const diffEntry of diff.changes) { + const originalRange = diffEntry.original; + if (originalModel) { + originalModel.tokenization.forceTokenization(Math.max(1, originalRange.endLineNumberExclusive - 1)); + } + const source = new LineSource( + (originalRange.length && originalModel) ? originalRange.mapToLineArray(l => originalModel.tokenization.getLineTokens(l)) : [], + [], + mightContainNonBasicASCII, + mightContainRTL, + ); + const decorations: InlineDecoration[] = []; + for (const i of diffEntry.innerChanges || []) { + decorations.push(new InlineDecoration( + i.originalRange.delta(-(diffEntry.original.startLineNumber - 1)), + diffDeleteDecoration.className!, + InlineDecorationType.Regular + )); + modifiedDecorations.push({ + range: i.modifiedRange, options: chatDiffAddDecoration + }); + } + if (!diffEntry.modified.isEmpty) { + modifiedDecorations.push({ + range: diffEntry.modified.toInclusiveRange()!, options: chatDiffWholeLineAddDecoration + }); + } + + if (diffEntry.original.isEmpty) { + // insertion + modifiedDecorations.push({ + range: diffEntry.modified.toInclusiveRange()!, + options: addedDecoration + }); + } else if (diffEntry.modified.isEmpty) { + // deletion + modifiedDecorations.push({ + range: new Range(diffEntry.modified.startLineNumber - 1, 1, diffEntry.modified.startLineNumber, 1), + options: deletedDecoration + }); + } else { + // modification + modifiedDecorations.push({ + range: diffEntry.modified.toInclusiveRange()!, + options: modifiedDecoration + }); + } + const domNode = document.createElement('div'); + domNode.className = 'chat-editing-original-zone view-lines line-delete monaco-mouse-cursor-text'; + const result = renderLines(source, renderOptions, decorations, domNode); + + const isCreatedContent = decorations.length === 1 && decorations[0].range.isEmpty() && decorations[0].range.startLineNumber === 1; + if (!isCreatedContent) { + const viewZoneData: IViewZone = { + afterLineNumber: diffEntry.modified.startLineNumber - 1, + heightInLines: result.heightInLines, + domNode, + ordinal: 50000 + 2 // more than https://github.com/microsoft/vscode/blob/bf52a5cfb2c75a7327c9adeaefbddc06d529dcad/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts#L42 + }; + + this._viewZones.push(viewZoneChangeAccessor.addZone(viewZoneData)); + } + } + + this._decorations.set(modifiedDecorations); + }); + } +} + +export class NotebookInsertedCellDecorator extends Disposable { + private readonly decorators = this._register(new DisposableStore()); + constructor( + private readonly notebookEditor: INotebookEditor, + ) { + super(); + + } + public apply(diffInfo: CellDiffInfo[]) { + const model = this.notebookEditor.textModel; + if (!model) { + return; + } + const cells = diffInfo.filter(diff => diff.type === 'insert').map((diff) => model.cells[diff.modifiedCellIndex]); + const ids = this.notebookEditor.deltaCellDecorations([], cells.map(cell => ({ + handle: cell.handle, + options: { className: 'nb-insertHighlight', outputClassName: 'nb-insertHighlight' } + }))); + this.clear(); + this.decorators.add(toDisposable(() => { + if (!this.notebookEditor.isDisposed) { + this.notebookEditor.deltaCellDecorations(ids, []); + } + })); + } + public clear() { + this.decorators.clear(); + } +} + +const ttPolicy = createTrustedTypesPolicy('notebookChatEditController', { createHTML: value => value }); + +export class NotebookDeletedCellDecorator extends Disposable { + private readonly zoneRemover = this._register(new DisposableStore()); + private readonly createdViewZones = new Map(); + constructor( + private readonly _notebookEditor: INotebookEditor, + @ILanguageService private readonly languageService: ILanguageService, + ) { + super(); + } + + + public apply(diffInfo: CellDiffInfo[], original: NotebookTextModel): void { + this.clear(); + + let currentIndex = 0; + const deletedCellsToRender: { cells: NotebookCellTextModel[]; index: number } = { cells: [], index: 0 }; + diffInfo.forEach(diff => { + if (diff.type === 'delete') { + const deletedCell = original.cells[diff.originalCellIndex]; + if (deletedCell) { + deletedCellsToRender.cells.push(deletedCell); + deletedCellsToRender.index = currentIndex; + } + } else { + if (deletedCellsToRender.cells.length) { + this._createWidget(deletedCellsToRender.index + 1, deletedCellsToRender.cells); + deletedCellsToRender.cells.length = 0; + } + currentIndex = diff.modifiedCellIndex; + } + }); + if (deletedCellsToRender.cells.length) { + this._createWidget(deletedCellsToRender.index + 1, deletedCellsToRender.cells); + } + } + + public clear() { + this.zoneRemover.clear(); + } + + + private _createWidget(index: number, cells: NotebookCellTextModel[]) { + this._createWidgetImpl(index, cells); + } + private async _createWidgetImpl(index: number, cells: NotebookCellTextModel[]) { + const rootContainer = document.createElement('div'); + const widgets = cells.map(cell => new NotebookDeletedCellWidget(this._notebookEditor, cell.getValue(), cell.language, rootContainer, this.languageService)); + const heights = await Promise.all(widgets.map(w => w.render())); + const totalHeight = heights.reduce((prev, curr) => prev + curr, 0); + + this._notebookEditor.changeViewZones(accessor => { + const notebookViewZone = { + afterModelPosition: index, + heightInPx: totalHeight + 4, + domNode: rootContainer + }; + + const id = accessor.addZone(notebookViewZone); + accessor.layoutZone(id); + this.createdViewZones.set(index, id); + this.zoneRemover.add(toDisposable(() => { + if (this.createdViewZones.get(index) === id) { + this.createdViewZones.delete(index); + } + if (!this._notebookEditor.isDisposed) { + this._notebookEditor.changeViewZones(accessor => { + accessor.removeZone(id); + dispose(widgets); + }); + } + })); + }); + } + +} + +export class NotebookDeletedCellWidget extends Disposable { + private readonly container: HTMLElement; + constructor( + private readonly _notebookEditor: INotebookEditor, + // private readonly _index: number, + private readonly code: string, + private readonly language: string, + container: HTMLElement, + @ILanguageService private readonly languageService: ILanguageService, + ) { + super(); + this.container = DOM.append(container, document.createElement('div')); + this._register(toDisposable(() => { + container.removeChild(this.container); + })); + } + + public async render() { + const code = this.code; + const languageId = this.language; + const codeHtml = await tokenizeToString(this.languageService, code, languageId); + + // const colorMap = this.getDefaultColorMap(); + const fontInfo = this._notebookEditor.getBaseCellEditorOptions(languageId).value; + const fontFamilyVar = '--notebook-editor-font-family'; + const fontSizeVar = '--notebook-editor-font-size'; + const fontWeightVar = '--notebook-editor-font-weight'; + // If we have any editors, then use left layout of one of those. + const editor = this._notebookEditor.codeEditors.map(c => c[1]).find(c => c); + const layoutInfo = editor?.getOptions().get(EditorOption.layoutInfo); + + const style = `` + + `font-family: var(${fontFamilyVar});` + + `font-weight: var(${fontWeightVar});` + + `font-size: var(${fontSizeVar});` + + fontInfo.lineHeight ? `line-height: ${fontInfo.lineHeight}px;` : '' + + layoutInfo?.contentLeft ? `margin-left: ${layoutInfo}px;` : '' + + `white-space: pre;`; + + + + const rootContainer = this.container; + rootContainer.classList.add('code-cell-row'); + const container = DOM.append(rootContainer, DOM.$('.cell-inner-container')); + const focusIndicatorLeft = DOM.append(container, DOM.$('.cell-focus-indicator.cell-focus-indicator-side.cell-focus-indicator-left')); + const cellContainer = DOM.append(container, DOM.$('.cell.code')); + DOM.append(focusIndicatorLeft, DOM.$('div.execution-count-label')); + const editorPart = DOM.append(cellContainer, DOM.$('.cell-editor-part')); + let editorContainer = DOM.append(editorPart, DOM.$('.cell-editor-container')); + editorContainer = DOM.append(editorContainer, DOM.$('.code', { style })); + if (fontInfo.fontFamily) { + editorContainer.style.setProperty(fontFamilyVar, fontInfo.fontFamily); + } + if (fontInfo.fontSize) { + editorContainer.style.setProperty(fontSizeVar, `${fontInfo.fontSize}px`); + } + if (fontInfo.fontWeight) { + editorContainer.style.setProperty(fontWeightVar, fontInfo.fontWeight); + } + editorContainer.innerHTML = (ttPolicy?.createHTML(codeHtml) || codeHtml) as string; + + const lineCount = splitLines(code).length; + const height = (lineCount * (fontInfo.lineHeight || DefaultLineHeight)) + 12 + 12; // We have 12px top and bottom in generated code HTML; + const totalHeight = height + 16 + 16; + + return totalHeight; + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatActionsOverlay.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatActionsOverlay.ts new file mode 100644 index 00000000000..4ae0d86dc35 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatActionsOverlay.ts @@ -0,0 +1,219 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { INotebookEditor } from '../notebookBrowser.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { ActionRunner, IAction, IActionRunner } from '../../../../../base/common/actions.js'; +import { $ } from '../../../../../base/browser/dom.js'; +import { IChatEditingService, IModifiedFileEntry } from '../../../chat/common/chatEditingService.js'; +import { ACTIVE_GROUP, IEditorService } from '../../../../services/editor/common/editorService.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { autorunWithStore, IObservable, observableFromEvent } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { CellDiffInfo } from '../diff/notebookDiffViewModel.js'; + +export class NotebookChatActionsOverlayController extends Disposable { + constructor( + private readonly notebookEditor: INotebookEditor, + cellDiffInfo: IObservable, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const notebookModel = observableFromEvent(this.notebookEditor.onDidChangeModel, e => e); + + this._register(autorunWithStore((r, store) => { + const session = this._chatEditingService.currentEditingSessionObs.read(r); + const model = notebookModel.read(r); + if (!model || !session) { + return; + } + + const entries = session.entries.read(r); + const idx = entries.findIndex(e => isEqual(e.modifiedURI, model.uri)); + if (idx >= 0) { + const entry = entries[idx]; + const nextEntry = entries[(idx + 1) % entries.length]; + const previousEntry = entries[(idx - 1 + entries.length) % entries.length]; + store.add(instantiationService.createInstance(NotebookChatActionsOverlay, notebookEditor, entry, cellDiffInfo, nextEntry, previousEntry)); + } + })); + } +} + +// Copied from src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts (until we unify these) +export class NotebookChatActionsOverlay extends Disposable { + constructor( + notebookEditor: INotebookEditor, + entry: IModifiedFileEntry, + cellDiffInfo: IObservable, + nextEntry: IModifiedFileEntry, + previousEntry: IModifiedFileEntry, + @IEditorService private readonly _editorService: IEditorService, + @IInstantiationService instaService: IInstantiationService, + ) { + super(); + const toolbarNode = $('div'); + toolbarNode.classList.add('notebook-chat-editor-overlay-widget'); + notebookEditor.getDomNode().appendChild(toolbarNode); + + this._register(toDisposable(() => { + notebookEditor.getDomNode().removeChild(toolbarNode); + })); + + const _toolbar = instaService.createInstance(MenuWorkbenchToolBar, toolbarNode, MenuId.ChatEditingEditorContent, { + telemetrySource: 'chatEditor.overlayToolbar', + hiddenItemStrategy: HiddenItemStrategy.Ignore, + toolbarOptions: { + primaryGroup: () => true, + useSeparatorsInPrimaryActions: true + }, + menuOptions: { renderShortTitle: true }, + actionViewItemProvider: (action, options) => { + const that = this; + if (action.id === 'chatEditor.action.accept' || action.id === 'chatEditor.action.reject') { + return new class extends ActionViewItem { + private readonly _reveal = this._store.add(new MutableDisposable()); + constructor() { + super(undefined, action, { ...options, icon: false, label: true, keybindingNotRenderedWithLabel: true }); + } + override set actionRunner(actionRunner: IActionRunner) { + super.actionRunner = actionRunner; + + const store = new DisposableStore(); + + store.add(actionRunner.onWillRun(_e => { + notebookEditor.focus(); + })); + store.add(actionRunner.onDidRun(e => { + if (e.action !== this.action) { + return; + } + if (entry === nextEntry) { + return; + } + const change = nextEntry.diffInfo.get().changes.at(0); + return that._editorService.openEditor({ + resource: nextEntry.modifiedURI, + options: { + selection: change && Range.fromPositions({ lineNumber: change.original.startLineNumber, column: 1 }), + revealIfOpened: false, + revealIfVisible: false, + } + }, ACTIVE_GROUP); + })); + + this._reveal.value = store; + } + override get actionRunner(): IActionRunner { + return super.actionRunner; + } + }; + } + // Override next/previous with our implementation. + if (action.id === 'chatEditor.action.navigateNext' || action.id === 'chatEditor.action.navigatePrevious') { + return new class extends ActionViewItem { + constructor() { + super(undefined, action, { ...options, icon: true, label: false, keybindingNotRenderedWithLabel: true }); + } + override set actionRunner(_: IActionRunner) { + const next = action.id === 'chatEditor.action.navigateNext' ? nextEntry : previousEntry; + const direction = action.id === 'chatEditor.action.navigateNext' ? 'next' : 'previous'; + super.actionRunner = new NextPreviousChangeActionRunner(notebookEditor, cellDiffInfo, entry, next, direction, _editorService); + } + override get actionRunner(): IActionRunner { + return super.actionRunner; + } + }; + } + return undefined; + } + + }); + + this._register(_toolbar); + } + + +} + +class NextPreviousChangeActionRunner extends ActionRunner { + constructor( + private readonly notebookEditor: INotebookEditor, + private readonly cellDiffInfo: IObservable, + private readonly entry: IModifiedFileEntry, + private readonly next: IModifiedFileEntry, + private readonly direction: 'next' | 'previous', + private readonly _editorService: IEditorService + ) { + super(); + } + protected override async runAction(_action: IAction, _context?: unknown): Promise { + const viewModel = this.notebookEditor.getViewModel(); + const activeCell = this.notebookEditor.activeCellAndCodeEditor; + const cellDiff = this.cellDiffInfo.read(undefined); + if (!viewModel || !activeCell || !cellDiff || !cellDiff.length) { + return this.goToNextEntry(); + } + + const activeCellIndex = viewModel.viewCells.findIndex(c => c.handle === activeCell[0].handle); + if (typeof activeCellIndex !== 'number') { + return this.goToNextEntry(); + } + + let index = this.getNextCellDiff(activeCellIndex, cellDiff); + if (typeof index === 'number') { + return this.notebookEditor.focusNotebookCell(viewModel.viewCells[index], 'container'); + } + + if (this.canGoToNextEntry()) { + return this.goToNextEntry(); + } + + // Cycle through edits in current notebook. + index = this.getNextCellDiff(this.direction === 'next' ? -1 : viewModel.viewCells.length + 1, cellDiff); + if (typeof index === 'number') { + return this.notebookEditor.focusNotebookCell(viewModel.viewCells[index], 'container'); + } + } + + private getNextCellDiff(currentCellIndex: number, cellDiffInfo: CellDiffInfo[]) { + if (this.direction === 'next') { + return cellDiffInfo + .filter(d => (d.type === 'insert' || d.type === 'modified') && d.modifiedCellIndex > currentCellIndex) + .map(d => d.type === 'insert' || d.type === 'modified' ? d.modifiedCellIndex : undefined)?.[0]; + } else { + return cellDiffInfo + .filter(d => (d.type === 'insert' || d.type === 'modified') && d.modifiedCellIndex < currentCellIndex) + .reverse() + .map(d => d.type === 'insert' || d.type === 'modified' ? d.modifiedCellIndex : undefined)?.[0]; + } + } + + private canGoToNextEntry() { + return this.entry !== this.next; + } + + private async goToNextEntry() { + if (!this.canGoToNextEntry()) { + return; + } + // For now just go to next/previous file. + const change = this.next.diffInfo.get().changes.at(0); + await this._editorService.openEditor({ + resource: this.next.modifiedURI, + options: { + selection: change && Range.fromPositions({ lineNumber: change.original.startLineNumber, column: 1 }), + revealIfOpened: false, + revealIfVisible: false, + } + }, ACTIVE_GROUP); + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatEditController.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatEditController.ts new file mode 100644 index 00000000000..ea71c8422b3 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatEditController.ts @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../../base/common/resources.js'; +import { Disposable, dispose, IDisposable, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived, derivedWithStore, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { IChatEditingService, WorkingSetEntryState } from '../../../chat/common/chatEditingService.js'; +import { NotebookTextModel } from '../../common/model/notebookTextModel.js'; +import { INotebookEditor, INotebookEditorContribution } from '../notebookBrowser.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { NotebookCellTextModel } from '../../common/model/notebookCellTextModel.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { NotebookDeletedCellDecorator, NotebookInsertedCellDecorator, NotebookCellDiffDecorator } from './notebookCellDecorators.js'; +import { INotebookModelSynchronizerFactory, NotebookModelSynchronizer } from './notebookSynchronizer.js'; +import { INotebookOriginalModelReferenceFactory } from './notebookOriginalModelRefFactory.js'; +import { debouncedObservable2 } from '../../../../../base/common/observableInternal/utils.js'; +import { CellDiffInfo } from '../diff/notebookDiffViewModel.js'; +import { NotebookChatActionsOverlayController } from './notebookChatActionsOverlay.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { localize } from '../../../../../nls.js'; + +export const ctxNotebookHasEditorModification = new RawContextKey('chat.hasNotebookEditorModifications', undefined, localize('chat.hasNotebookEditorModifications', "The current Notebook editor contains chat modifications")); + +export class NotebookChatEditorControllerContrib extends Disposable implements INotebookEditorContribution { + + public static readonly ID: string = 'workbench.notebook.chatEditorController'; + readonly _serviceBrand: undefined; + constructor( + notebookEditor: INotebookEditor, + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + + ) { + super(); + if (configurationService.getValue('notebook.experimental.chatEdits')) { + this._register(instantiationService.createInstance(NotebookChatEditorController, notebookEditor)); + } + } +} + + +class NotebookChatEditorController extends Disposable { + private readonly deletedCellDecorator: NotebookDeletedCellDecorator; + private readonly insertedCellDecorator: NotebookInsertedCellDecorator; + private readonly _ctxHasEditorModification: IContextKey; + constructor( + private readonly notebookEditor: INotebookEditor, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @INotebookOriginalModelReferenceFactory private readonly originalModelRefFactory: INotebookOriginalModelReferenceFactory, + @INotebookModelSynchronizerFactory private readonly synchronizerFactory: INotebookModelSynchronizerFactory, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + this._ctxHasEditorModification = ctxNotebookHasEditorModification.bindTo(contextKeyService); + this.deletedCellDecorator = this._register(instantiationService.createInstance(NotebookDeletedCellDecorator, notebookEditor)); + this.insertedCellDecorator = this._register(instantiationService.createInstance(NotebookInsertedCellDecorator, notebookEditor)); + const notebookModel = observableFromEvent(this.notebookEditor.onDidChangeModel, e => e); + const originalModel = observableValue('originalModel', undefined); + const viewModelAttached = observableFromEvent(this.notebookEditor.onDidAttachViewModel, () => !!this.notebookEditor.getViewModel()); + const onDidChangeVisibleRanges = debouncedObservable2(observableFromEvent(this.notebookEditor.onDidChangeVisibleRanges, () => this.notebookEditor.visibleRanges), 100); + const decorators = new Map(); + + let updatedCellDecoratorsOnceBefore = false; + let updatedDeletedInsertedDecoratorsOnceBefore = false; + + + const clearDecorators = () => { + dispose(Array.from(decorators.values())); + decorators.clear(); + this.deletedCellDecorator.clear(); + this.insertedCellDecorator.clear(); + }; + + this._register(toDisposable(() => clearDecorators())); + + let notebookSynchronizer: IReference; + const entryObs = derived((r) => { + const session = this._chatEditingService.currentEditingSessionObs.read(r); + const model = notebookModel.read(r); + if (!model || !session) { + return; + } + return session.entries.read(r).find(e => isEqual(e.modifiedURI, model.uri)); + }).recomputeInitiallyAndOnChange(this._store); + + + this._register(autorun(r => { + const entry = entryObs.read(r); + const model = notebookModel.read(r); + if (!entry || !model || entry.state.read(r) !== WorkingSetEntryState.Modified) { + clearDecorators(); + } + })); + + const notebookDiffInfo = derivedWithStore(this, (r, store) => { + const entry = entryObs.read(r); + const model = notebookModel.read(r); + if (!entry || !model) { + // If entry is undefined, then revert the changes to the notebook. + if (notebookSynchronizer && model) { + notebookSynchronizer.object.revert(); + } + return observableValue<{ + cellDiff: CellDiffInfo[]; + modelVersion: number; + } | undefined>('DefaultDiffIno', undefined); + } + + notebookSynchronizer = notebookSynchronizer || this._register(this.synchronizerFactory.getOrCreate(model)); + this.originalModelRefFactory.getOrCreate(entry, model.viewType).then(ref => originalModel.set(this._register(ref).object, undefined)); + + return notebookSynchronizer.object.diffInfo; + }).recomputeInitiallyAndOnChange(this._store).flatten(); + + const notebookCellDiffInfo = notebookDiffInfo.map(d => d?.cellDiff); + this._register(instantiationService.createInstance(NotebookChatActionsOverlayController, notebookEditor, notebookCellDiffInfo)); + + this._register(autorun(r => { + // If we have a new entry for the file, then clear old decorators. + // User could be cycling through different edit sessions (Undo Last Edit / Redo Last Edit). + entryObs.read(r); + clearDecorators(); + })); + + this._register(autorun(r => { + // If there's no diff info, then we either accepted or rejected everything. + const diffs = notebookDiffInfo.read(r); + if (!diffs || !diffs.cellDiff.length) { + clearDecorators(); + this._ctxHasEditorModification.reset(); + } else { + this._ctxHasEditorModification.set(true); + } + })); + + this._register(autorun(r => { + const entry = entryObs.read(r); + const diffInfo = notebookDiffInfo.read(r); + const modified = notebookModel.read(r); + const original = originalModel.read(r); + onDidChangeVisibleRanges.read(r); + + if (!entry || !modified || !original || !diffInfo) { + return; + } + if (diffInfo && updatedCellDecoratorsOnceBefore && (diffInfo.modelVersion !== modified.versionId)) { + return; + } + + updatedCellDecoratorsOnceBefore = true; + diffInfo.cellDiff.forEach((diff) => { + if (diff.type === 'modified') { + const modifiedCell = modified.cells[diff.modifiedCellIndex]; + const originalCellValue = original.cells[diff.originalCellIndex].getValue(); + const editor = this.notebookEditor.codeEditors.find(([vm,]) => vm.handle === modifiedCell.handle)?.[1]; + if (editor && decorators.get(modifiedCell)?.editor !== editor) { + decorators.get(modifiedCell)?.dispose(); + const decorator = this.instantiationService.createInstance(NotebookCellDiffDecorator, editor, originalCellValue, modifiedCell.cellKind); + decorators.set(modifiedCell, decorator); + this._register(editor.onDidDispose(() => { + decorator.dispose(); + if (decorators.get(modifiedCell) === decorator) { + decorators.delete(modifiedCell); + } + })); + } + } + }); + })); + + this._register(autorun(r => { + const entry = entryObs.read(r); + const diffInfo = notebookDiffInfo.read(r); + const modified = notebookModel.read(r); + const original = originalModel.read(r); + const vmAttached = viewModelAttached.read(r); + if (!vmAttached || !entry || !modified || !original || !diffInfo) { + return; + } + if (diffInfo && updatedDeletedInsertedDecoratorsOnceBefore && (diffInfo.modelVersion !== modified.versionId)) { + return; + } + updatedDeletedInsertedDecoratorsOnceBefore = true; + this.insertedCellDecorator.apply(diffInfo.cellDiff); + this.deletedCellDecorator.apply(diffInfo.cellDiff, original); + })); + } + +} diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalCellModelFactory.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalCellModelFactory.ts new file mode 100644 index 00000000000..c2a08c74da1 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalCellModelFactory.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IReference, ReferenceCollection } from '../../../../../base/common/lifecycle.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { CellKind } from '../../common/notebookCommon.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; + + +export const INotebookOriginalCellModelFactory = createDecorator('INotebookOriginalCellModelFactory'); + +export interface INotebookOriginalCellModelFactory { + readonly _serviceBrand: undefined; + getOrCreate(uri: URI, cellValue: string, language: string, cellKind: CellKind): IReference; +} + + +export class OriginalNotebookCellModelReferenceCollection extends ReferenceCollection { + constructor(@IModelService private readonly modelService: IModelService, + @ILanguageService private readonly _languageService: ILanguageService, + ) { + super(); + } + + protected override createReferencedObject(_key: string, uri: URI, cellValue: string, language: string, cellKind: CellKind): ITextModel { + const scheme = `${uri.scheme}-chat-edit`; + const originalCellUri = URI.from({ scheme, fragment: uri.fragment, path: uri.path }); + const languageSelection = this._languageService.getLanguageIdByLanguageName(language) ? this._languageService.createById(language) : cellKind === CellKind.Markup ? this._languageService.createById('markdown') : null; + return this.modelService.createModel(cellValue, languageSelection, originalCellUri); + } + protected override destroyReferencedObject(_key: string, model: ITextModel): void { + model.dispose(); + } +} + +export class OriginalNotebookCellModelFactory implements INotebookOriginalCellModelFactory { + readonly _serviceBrand: undefined; + private readonly _data: OriginalNotebookCellModelReferenceCollection; + constructor(@IInstantiationService instantiationService: IInstantiationService) { + this._data = instantiationService.createInstance(OriginalNotebookCellModelReferenceCollection); + } + + getOrCreate(uri: URI, cellValue: string, language: string, cellKind: CellKind): IReference { + return this._data.acquire(uri.toString(), uri, cellValue, language, cellKind); + } +} + + diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalModelRefFactory.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalModelRefFactory.ts new file mode 100644 index 00000000000..e0a38d61874 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalModelRefFactory.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AsyncReferenceCollection, IReference, ReferenceCollection } from '../../../../../base/common/lifecycle.js'; +import { IModifiedFileEntry } from '../../../chat/common/chatEditingService.js'; +import { INotebookService } from '../../common/notebookService.js'; +import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; +import { NotebookTextModel } from '../../common/model/notebookTextModel.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; + + +export const INotebookOriginalModelReferenceFactory = createDecorator('INotebookOriginalModelReferenceFactory'); + +export interface INotebookOriginalModelReferenceFactory { + readonly _serviceBrand: undefined; + getOrCreate(fileEntry: IModifiedFileEntry, viewType: string): Promise>; +} + + +export class OriginalNotebookModelReferenceCollection extends ReferenceCollection> { + private readonly modelsToDispose = new Set(); + constructor(@INotebookService private readonly notebookService: INotebookService) { + super(); + } + + protected override async createReferencedObject(key: string, fileEntry: IModifiedFileEntry, viewType: string): Promise { + this.modelsToDispose.delete(key); + const uri = fileEntry.originalURI; + const model = this.notebookService.getNotebookTextModel(uri); + if (model) { + return model; + } + const bytes = VSBuffer.fromString(fileEntry.originalModel.getValue()); + const stream = bufferToStream(bytes); + + return this.notebookService.createNotebookTextModel(viewType, uri, stream); + } + protected override destroyReferencedObject(key: string, modelPromise: Promise): void { + this.modelsToDispose.add(key); + + (async () => { + try { + const model = await modelPromise; + + if (!this.modelsToDispose.has(key)) { + // return if model has been acquired again meanwhile + return; + } + + // Finally we can dispose the model + model.dispose(); + } catch (error) { + // ignore + } finally { + this.modelsToDispose.delete(key); // Untrack as being disposed + } + })(); + } +} + +export class NotebookOriginalModelReferenceFactory implements INotebookOriginalModelReferenceFactory { + readonly _serviceBrand: undefined; + private _resourceModelCollection: OriginalNotebookModelReferenceCollection & ReferenceCollection> /* TS Fail */ | undefined = undefined; + private get resourceModelCollection() { + if (!this._resourceModelCollection) { + this._resourceModelCollection = this.instantiationService.createInstance(OriginalNotebookModelReferenceCollection); + } + + return this._resourceModelCollection; + } + + private _asyncModelCollection: AsyncReferenceCollection | undefined = undefined; + private get asyncModelCollection() { + if (!this._asyncModelCollection) { + this._asyncModelCollection = new AsyncReferenceCollection(this.resourceModelCollection); + } + + return this._asyncModelCollection; + } + + constructor(@IInstantiationService private readonly instantiationService: IInstantiationService) { + } + + getOrCreate(fileEntry: IModifiedFileEntry, viewType: string): Promise> { + return this.asyncModelCollection.acquire(fileEntry.originalURI.toString(), fileEntry, viewType); + } +} + diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookSynchronizer.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookSynchronizer.ts new file mode 100644 index 00000000000..b26454f37e6 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookSynchronizer.ts @@ -0,0 +1,389 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../../base/common/resources.js'; +import { Disposable, IReference, ReferenceCollection } from '../../../../../base/common/lifecycle.js'; +import { IChatEditingService, IModifiedFileEntry, WorkingSetEntryState } from '../../../chat/common/chatEditingService.js'; +import { INotebookService } from '../../common/notebookService.js'; +import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; +import { NotebookTextModel } from '../../common/model/notebookTextModel.js'; +import { raceCancellation, ThrottledDelayer } from '../../../../../base/common/async.js'; +import { CellDiffInfo, computeDiff, prettyChanges } from '../diff/notebookDiffViewModel.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { INotebookEditorWorkerService } from '../../common/services/notebookWorkerService.js'; +import { ChatEditingModifiedFileEntry } from '../../../chat/browser/chatEditing/chatEditingModifiedFileEntry.js'; +import { CellEditType, ICellDto2, ICellReplaceEdit, NotebookData, NotebookSetting } from '../../common/notebookCommon.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { EditOperation } from '../../../../../editor/common/core/editOperation.js'; +import { INotebookLoggingService } from '../../common/notebookLoggingService.js'; +import { filter } from '../../../../../base/common/objects.js'; +import { INotebookEditorModelResolverService } from '../../common/notebookEditorModelResolverService.js'; +import { SaveReason } from '../../../../common/editor.js'; +import { IChatService } from '../../../chat/common/chatService.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { INotebookOriginalModelReferenceFactory } from './notebookOriginalModelRefFactory.js'; +import { autorun, autorunWithStore, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; + + +export const INotebookModelSynchronizerFactory = createDecorator('INotebookModelSynchronizerFactory'); + +export interface INotebookModelSynchronizerFactory { + readonly _serviceBrand: undefined; + getOrCreate(model: NotebookTextModel): IReference; +} + +class NotebookModelSynchronizerReferenceCollection extends ReferenceCollection { + constructor(@IInstantiationService private readonly instantiationService: IInstantiationService) { + super(); + } + protected override createReferencedObject(_key: string, model: NotebookTextModel): NotebookModelSynchronizer { + return this.instantiationService.createInstance(NotebookModelSynchronizer, model); + } + protected override destroyReferencedObject(_key: string, object: NotebookModelSynchronizer): void { + object.dispose(); + } +} + +export class NotebookModelSynchronizerFactory implements INotebookModelSynchronizerFactory { + readonly _serviceBrand: undefined; + private readonly _data: NotebookModelSynchronizerReferenceCollection; + constructor(@IInstantiationService instantiationService: IInstantiationService) { + this._data = instantiationService.createInstance(NotebookModelSynchronizerReferenceCollection); + } + + getOrCreate(model: NotebookTextModel): IReference { + return this._data.acquire(model.uri.toString(), model); + } +} + + +export class NotebookModelSynchronizer extends Disposable { + private readonly throttledUpdateNotebookModel = new ThrottledDelayer(200); + private _diffInfo = observableValue<{ cellDiff: CellDiffInfo[]; modelVersion: number } | undefined>('diffInfo', undefined); + public get diffInfo(): IObservable<{ cellDiff: CellDiffInfo[]; modelVersion: number } | undefined> { + return this._diffInfo; + } + private snapshot?: { bytes: VSBuffer; dirty: boolean }; + private isEditFromUs: boolean = false; + constructor( + private readonly model: NotebookTextModel, + @IChatEditingService _chatEditingService: IChatEditingService, + @INotebookService private readonly notebookService: INotebookService, + @IChatService chatService: IChatService, + @INotebookLoggingService private readonly logService: INotebookLoggingService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @INotebookEditorWorkerService private readonly notebookEditorWorkerService: INotebookEditorWorkerService, + @INotebookEditorModelResolverService private readonly notebookModelResolverService: INotebookEditorModelResolverService, + @INotebookOriginalModelReferenceFactory private readonly originalModelRefFactory: INotebookOriginalModelReferenceFactory, + ) { + super(); + + const entryObs = derived((r) => { + const session = _chatEditingService.currentEditingSessionObs.read(r); + if (!session) { + return; + } + return session.entries.read(r).find(e => isEqual(e.modifiedURI, model.uri)); + }).recomputeInitiallyAndOnChange(this._store); + + + this._register(chatService.onDidPerformUserAction(async e => { + const entry = entryObs.read(undefined); + if (!entry) { + return; + } + if (e.action.kind === 'chatEditingSessionAction' && !e.action.hasRemainingEdits && isEqual(e.action.uri, entry.modifiedURI)) { + if (e.action.outcome === 'accepted') { + await this.accept(entry); + await this.createSnapshot(); + this._diffInfo.set(undefined, undefined); + } + else if (e.action.outcome === 'rejected') { + await this.revertImpl(); + } + } + })); + + const updateNotebookModel = (entry: IModifiedFileEntry, token: CancellationToken) => { + this.throttledUpdateNotebookModel.trigger(() => this.updateNotebookModel(entry, token)); + }; + + this._register(autorun(async (r) => { + const entry = entryObs.read(r); + if (!entry) { + return; + } + if (entry.state.read(r) === WorkingSetEntryState.Rejected) { + await this.revertImpl(); + } + })); + + let snapshotCreated = false; + this._register(autorunWithStore((r, store) => { + const entry = entryObs.read(r); + if (!entry) { + return; + } + if (!snapshotCreated) { + this.createSnapshot(); + snapshotCreated = true; + } + + const modifiedModel = (entry as ChatEditingModifiedFileEntry).modifiedModel; + let cancellationToken = store.add(new CancellationTokenSource()); + store.add(modifiedModel.onDidChangeContent(async () => { + if (!modifiedModel.isDisposed() && !entry.originalModel.isDisposed() && modifiedModel.getValue() !== entry.originalModel.getValue()) { + cancellationToken = store.add(new CancellationTokenSource()); + updateNotebookModel(entry, cancellationToken.token); + } + })); + + updateNotebookModel(entry, cancellationToken.token); + })); + + this._register(model.onDidChangeContent(() => { + // Track changes from the user. + if (!this.isEditFromUs && this.snapshot) { + this.snapshot.dirty = true; + } + })); + } + + private async createSnapshot() { + const [serializer, ref] = await Promise.all([ + this.getNotebookSerializer(), + this.notebookModelResolverService.resolve(this.model.uri) + ]); + + try { + const data: NotebookData = { + metadata: filter(this.model.metadata, key => !serializer.options.transientDocumentMetadata[key]), + cells: [], + }; + + let outputSize = 0; + for (const cell of this.model.cells) { + const cellData: ICellDto2 = { + cellKind: cell.cellKind, + language: cell.language, + mime: cell.mime, + source: cell.getValue(), + outputs: [], + internalMetadata: cell.internalMetadata + }; + + const outputSizeLimit = this.configurationService.getValue(NotebookSetting.outputBackupSizeLimit) * 1024; + if (outputSizeLimit > 0) { + cell.outputs.forEach(output => { + output.outputs.forEach(item => { + outputSize += item.data.byteLength; + }); + }); + if (outputSize > outputSizeLimit) { + return; + } + } + + cellData.outputs = !serializer.options.transientOutputs ? cell.outputs : []; + cellData.metadata = filter(cell.metadata, key => !serializer.options.transientCellMetadata[key]); + + data.cells.push(cellData); + } + + const bytes = await serializer.notebookToData(data); + this.snapshot = { bytes, dirty: ref.object.isDirty() }; + } finally { + ref.dispose(); + } + } + + public async revert() { + await this.revertImpl(); + } + + private async revertImpl(): Promise { + if (!this.snapshot) { + return; + } + await this.updateNotebook(this.snapshot.bytes, !this.snapshot.dirty); + this._diffInfo.set(undefined, undefined); + } + + private async updateNotebook(bytes: VSBuffer, save: boolean) { + const ref = await this.notebookModelResolverService.resolve(this.model.uri); + try { + const serializer = await this.getNotebookSerializer(); + const data = await serializer.dataToNotebook(bytes); + this.model.reset(data.cells, data.metadata, serializer.options); + + if (save) { + // save the file after discarding so that the dirty indicator goes away + // and so that an intermediate saved state gets reverted + // await this.notebookEditor.textModel.save({ reason: SaveReason.EXPLICIT }); + await ref.object.save({ reason: SaveReason.EXPLICIT }); + } + } finally { + ref.dispose(); + } + } + + private async accept(entry: IModifiedFileEntry) { + const modifiedModel = (entry as ChatEditingModifiedFileEntry).modifiedModel; + const content = modifiedModel.getValue(); + await this.updateNotebook(VSBuffer.fromString(content), false); + } + + async getNotebookSerializer() { + const info = await this.notebookService.withNotebookDataProvider(this.model.viewType); + return info.serializer; + } + + private _originalModel?: Promise; + private async getOriginalModel(entry: IModifiedFileEntry): Promise { + if (!this._originalModel) { + this._originalModel = this.originalModelRefFactory.getOrCreate(entry, this.model.viewType).then(ref => this._register(ref).object); + } + return this._originalModel; + } + private async updateNotebookModel(entry: IModifiedFileEntry, token: CancellationToken) { + const modifiedModelVersion = (entry as ChatEditingModifiedFileEntry).modifiedModel.getVersionId(); + const currentModel = this.model; + const modelVersion = currentModel?.versionId ?? 0; + const modelWithChatEdits = await this.getModifiedModelForDiff(entry, token); + if (!modelWithChatEdits || token.isCancellationRequested || !currentModel) { + return; + } + const originalModel = await this.getOriginalModel(entry); + // This is the total diff from the original model to the model with chat edits. + const cellDiffInfo = (await this.computeDiff(originalModel, modelWithChatEdits, token))?.cellDiffInfo; + // This is the diff from the current model to the model with chat edits. + const cellDiffInfoToApplyEdits = (await this.computeDiff(currentModel, modelWithChatEdits, token))?.cellDiffInfo; + const currentVersion = (entry as ChatEditingModifiedFileEntry).modifiedModel.getVersionId(); + if (!cellDiffInfo || !cellDiffInfoToApplyEdits || token.isCancellationRequested || currentVersion !== modifiedModelVersion || modelVersion !== currentModel.versionId) { + return; + } + if (cellDiffInfoToApplyEdits.every(d => d.type === 'unchanged')) { + return; + } + + // All edits from here on are from us. + this.isEditFromUs = true; + try { + const edits: ICellReplaceEdit[] = []; + const mappings = new Map(); + + // First Delete. + const deletedIndexes: number[] = []; + cellDiffInfoToApplyEdits.reverse().forEach(diff => { + if (diff.type === 'delete') { + deletedIndexes.push(diff.originalCellIndex); + edits.push({ + editType: CellEditType.Replace, + index: diff.originalCellIndex, + cells: [], + count: 1 + }); + } + }); + if (edits.length) { + currentModel.applyEdits(edits, true, undefined, () => undefined, undefined, false); + edits.length = 0; + } + + // Next insert. + cellDiffInfoToApplyEdits.reverse().forEach(diff => { + if (diff.type === 'modified' || diff.type === 'unchanged') { + mappings.set(diff.modifiedCellIndex, diff.originalCellIndex); + } + if (diff.type === 'insert') { + const originalIndex = mappings.get(diff.modifiedCellIndex - 1) ?? 0; + mappings.set(diff.modifiedCellIndex, originalIndex); + const cell = modelWithChatEdits.cells[diff.modifiedCellIndex]; + const newCell: ICellDto2 = + { + source: cell.getValue(), + cellKind: cell.cellKind, + language: cell.language, + outputs: cell.outputs.map(output => output.asDto()), + mime: cell.mime, + metadata: cell.metadata, + collapseState: cell.collapseState, + internalMetadata: cell.internalMetadata + }; + edits.push({ + editType: CellEditType.Replace, + index: originalIndex + 1, + cells: [newCell], + count: 0 + }); + } + }); + if (edits.length) { + currentModel.applyEdits(edits, true, undefined, () => undefined, undefined, false); + edits.length = 0; + } + + // Finally update + cellDiffInfoToApplyEdits.forEach(diff => { + if (diff.type === 'modified') { + const cell = currentModel.cells[diff.originalCellIndex]; + const textModel = cell.textModel; + if (textModel) { + const newText = modelWithChatEdits.cells[diff.modifiedCellIndex].getValue(); + textModel.pushEditOperations(null, [ + EditOperation.replace(textModel.getFullModelRange(), newText) + ], () => null); + } + } + }); + + if (edits.length) { + currentModel.applyEdits(edits, true, undefined, () => undefined, undefined, false); + } + this._diffInfo.set({ cellDiff: cellDiffInfo, modelVersion: currentModel.versionId }, undefined); + } + finally { + this.isEditFromUs = false; + } + } + private previousUriOfModelForDiff?: URI; + + private async getModifiedModelForDiff(entry: IModifiedFileEntry, token: CancellationToken): Promise { + const text = (entry as ChatEditingModifiedFileEntry).modifiedModel.getValue(); + const bytes = VSBuffer.fromString(text); + const uri = entry.modifiedURI.with({ scheme: `NotebookChatEditorController.modifiedScheme${Date.now().toString()}` }); + const stream = bufferToStream(bytes); + if (this.previousUriOfModelForDiff) { + this.notebookService.getNotebookTextModel(this.previousUriOfModelForDiff)?.dispose(); + } + this.previousUriOfModelForDiff = uri; + try { + const model = await this.notebookService.createNotebookTextModel(this.model.viewType, uri, stream); + if (token.isCancellationRequested) { + model.dispose(); + return; + } + this._register(model); + return model; + } catch (ex) { + this.logService.warn('NotebookChatEdit', `Failed to deserialize Notebook for ${uri.toString}, ${ex.message}`); + this.logService.debug('NotebookChatEdit', ex.toString()); + return; + } + } + + async computeDiff(original: NotebookTextModel, modified: NotebookTextModel, token: CancellationToken) { + const diffResult = await raceCancellation(this.notebookEditorWorkerService.computeDiff(original.uri, modified.uri), token); + if (!diffResult || token.isCancellationRequested) { + // after await the editor might be disposed. + return; + } + + prettyChanges(original, modified, diffResult.cellsDiff); + + return computeDiff(original, modified, diffResult); + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticEditorContrib.ts b/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticEditorContrib.ts index d5af8a45494..91ffcfae0f7 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticEditorContrib.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticEditorContrib.ts @@ -6,30 +6,23 @@ import { Disposable, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { IMarkerData, IMarkerService } from '../../../../../../platform/markers/common/markers.js'; import { IRange } from '../../../../../../editor/common/core/range.js'; -import { ICellExecutionError, ICellExecutionStateChangedEvent, IExecutionStateChangedEvent, INotebookExecutionStateService, NotebookExecutionType } from '../../../common/notebookExecutionStateService.js'; +import { ICellExecutionStateChangedEvent, IExecutionStateChangedEvent, INotebookExecutionStateService, NotebookExecutionType } from '../../../common/notebookExecutionStateService.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { CellKind, NotebookSetting } from '../../../common/notebookCommon.js'; import { INotebookEditor, INotebookEditorContribution } from '../../notebookBrowser.js'; import { registerNotebookContribution } from '../../notebookEditorExtensions.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; import { CodeCellViewModel } from '../../viewModel/codeCellViewModel.js'; -import { URI } from '../../../../../../base/common/uri.js'; import { Event } from '../../../../../../base/common/event.js'; import { IChatAgentService } from '../../../../chat/common/chatAgents.js'; -type CellDiagnostic = { - cellUri: URI; - error: ICellExecutionError; - disposables: IDisposable[]; -}; - export class CellDiagnostics extends Disposable implements INotebookEditorContribution { static ID: string = 'workbench.notebook.cellDiagnostics'; private enabled = false; private listening = false; - private diagnosticsByHandle: Map = new Map(); + private diagnosticsByHandle: Map = new Map(); constructor( private readonly notebookEditor: INotebookEditor, @@ -66,8 +59,6 @@ export class CellDiagnostics extends Disposable implements INotebookEditorContri } } - - private handleChangeExecutionState(changes: (ICellExecutionStateChangedEvent | IExecutionStateChangedEvent)[]) { if (!this.enabled) { return; @@ -96,9 +87,9 @@ export class CellDiagnostics extends Disposable implements INotebookEditorContri } public clear(cellHandle: number) { - const diagnostic = this.diagnosticsByHandle.get(cellHandle); - if (diagnostic) { - for (const disposable of diagnostic.disposables) { + const disposables = this.diagnosticsByHandle.get(cellHandle); + if (disposables) { + for (const disposable of disposables) { disposable.dispose(); } this.diagnosticsByHandle.delete(cellHandle); @@ -119,11 +110,10 @@ export class CellDiagnostics extends Disposable implements INotebookEditorContri const metadata = cell.model.internalMetadata; if (cell instanceof CodeCellViewModel && !metadata.lastRunSuccess && metadata?.error?.location) { const disposables: IDisposable[] = []; - const marker = this.createMarkerData(metadata.error.message, metadata.error.location); + const errorLabel = metadata.error.name ? `${metadata.error.name}: ${metadata.error.message}` : metadata.error.message; + const marker = this.createMarkerData(errorLabel, metadata.error.location); this.markerService.changeOne(CellDiagnostics.ID, cell.uri, [marker]); disposables.push(toDisposable(() => this.markerService.changeOne(CellDiagnostics.ID, cell.uri, []))); - cell.excecutionError.set(metadata.error, undefined); - disposables.push(toDisposable(() => cell.excecutionError.set(undefined, undefined))); disposables.push(cell.model.onDidChangeOutputs(() => { if (cell.model.outputs.length === 0) { this.clear(cellHandle); @@ -132,7 +122,7 @@ export class CellDiagnostics extends Disposable implements INotebookEditorContri disposables.push(cell.model.onDidChangeContent(() => { this.clear(cellHandle); })); - this.diagnosticsByHandle.set(cellHandle, { cellUri: cell.uri, error: metadata.error, disposables }); + this.diagnosticsByHandle.set(cellHandle, disposables); } } diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticsActions.ts b/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticsActions.ts index f619c126da2..4bd31db8d75 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticsActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnosticsActions.ts @@ -15,8 +15,13 @@ import { KeybindingWeight } from '../../../../../../platform/keybinding/common/k import { INotebookCellActionContext, NotebookCellAction, findTargetCellEditor } from '../../controller/coreActions.js'; import { CodeCellViewModel } from '../../viewModel/codeCellViewModel.js'; import { NOTEBOOK_CELL_EDITOR_FOCUSED, NOTEBOOK_CELL_FOCUSED, NOTEBOOK_CELL_HAS_ERROR_DIAGNOSTICS } from '../../../common/notebookContextKeys.js'; +import { InlineChatController } from '../../../../inlineChat/browser/inlineChatController.js'; +import { showChatView } from '../../../../chat/browser/chat.js'; +import { IViewsService } from '../../../../../services/views/common/viewsService.js'; export const OPEN_CELL_FAILURE_ACTIONS_COMMAND_ID = 'notebook.cell.openFailureActions'; +export const FIX_CELL_ERROR_COMMAND_ID = 'notebook.cell.chat.fixError'; +export const EXPLAIN_CELL_ERROR_COMMAND_ID = 'notebook.cell.chat.explainError'; registerAction2(class extends NotebookCellAction { constructor() { @@ -35,7 +40,7 @@ registerAction2(class extends NotebookCellAction { async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext): Promise { if (context.cell instanceof CodeCellViewModel) { - const error = context.cell.excecutionError.get(); + const error = context.cell.executionError.get(); if (error?.location) { const location = Range.lift({ startLineNumber: error.location.startLineNumber + 1, @@ -56,3 +61,61 @@ registerAction2(class extends NotebookCellAction { } } }); + +registerAction2(class extends NotebookCellAction { + constructor() { + super({ + id: FIX_CELL_ERROR_COMMAND_ID, + title: localize2('notebookActions.chatFixCellError', "Fix Cell Error"), + precondition: ContextKeyExpr.and(NOTEBOOK_CELL_FOCUSED, NOTEBOOK_CELL_HAS_ERROR_DIAGNOSTICS, NOTEBOOK_CELL_EDITOR_FOCUSED.toNegated()), + f1: true + }); + } + + async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext): Promise { + if (context.cell instanceof CodeCellViewModel) { + const error = context.cell.executionError.get(); + if (error?.location) { + const location = Range.lift({ + startLineNumber: error.location.startLineNumber + 1, + startColumn: error.location.startColumn + 1, + endLineNumber: error.location.endLineNumber + 1, + endColumn: error.location.endColumn + 1 + }); + context.notebookEditor.setCellEditorSelection(context.cell, Range.lift(location)); + const editor = findTargetCellEditor(context, context.cell); + if (editor) { + const controller = InlineChatController.get(editor); + const message = error.name ? `${error.name}: ${error.message}` : error.message; + if (controller) { + await controller.run({ message: '/fix ' + message, initialRange: location, autoSend: true }); + } + } + } + } + } +}); + +registerAction2(class extends NotebookCellAction { + constructor() { + super({ + id: EXPLAIN_CELL_ERROR_COMMAND_ID, + title: localize2('notebookActions.chatExplainCellError', "Explain Cell Error"), + precondition: ContextKeyExpr.and(NOTEBOOK_CELL_FOCUSED, NOTEBOOK_CELL_HAS_ERROR_DIAGNOSTICS, NOTEBOOK_CELL_EDITOR_FOCUSED.toNegated()), + f1: true + }); + } + + async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext): Promise { + if (context.cell instanceof CodeCellViewModel) { + const error = context.cell.executionError.get(); + if (error?.message) { + const viewsService = accessor.get(IViewsService); + const chatWidget = await showChatView(viewsService); + const message = error.name ? `${error.name}: ${error.message}` : error.message; + // TODO: can we add special prompt instructions? e.g. use "%pip install" + chatWidget?.acceptInput('@workspace /explain ' + message,); + } + } + } +}); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/diagnosticCellStatusBarContrib.ts b/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/diagnosticCellStatusBarContrib.ts index 561addf90df..43d059283cc 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/diagnosticCellStatusBarContrib.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/diagnosticCellStatusBarContrib.ts @@ -7,8 +7,7 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { localize } from '../../../../../../nls.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; -import { OPEN_CELL_FAILURE_ACTIONS_COMMAND_ID } from './cellDiagnosticsActions.js'; +import { EXPLAIN_CELL_ERROR_COMMAND_ID, FIX_CELL_ERROR_COMMAND_ID } from './cellDiagnosticsActions.js'; import { NotebookStatusBarController } from '../cellStatusBar/executionStatusBarItemController.js'; import { INotebookEditor, INotebookEditorContribution, INotebookViewModel } from '../../notebookBrowser.js'; import { registerNotebookContribution } from '../../notebookEditorExtensions.js'; @@ -39,31 +38,31 @@ class DiagnosticCellStatusBarItem extends Disposable { constructor( private readonly _notebookViewModel: INotebookViewModel, - private readonly cell: CodeCellViewModel, - @IKeybindingService private readonly keybindingService: IKeybindingService, + private readonly cell: CodeCellViewModel ) { super(); - this._register(autorun((reader) => this.updateSparkleItem(reader.readObservable(cell.excecutionError)))); - + this._register(autorun((reader) => this.updateQuickActions(reader.readObservable(cell.executionError)))); } - private async updateSparkleItem(error: ICellExecutionError | undefined) { - let item: INotebookCellStatusBarItem | undefined; + private async updateQuickActions(error: ICellExecutionError | undefined) { + let items: INotebookCellStatusBarItem[] = []; if (error?.location) { - const keybinding = this.keybindingService.lookupKeybinding(OPEN_CELL_FAILURE_ACTIONS_COMMAND_ID)?.getLabel(); - const tooltip = localize('notebook.cell.status.diagnostic', "Quick Actions {0}", `(${keybinding})`); - - item = { - text: `$(sparkle)`, - tooltip, + items = [{ + text: `$(sparkle) fix`, + tooltip: localize('notebook.cell.status.fix', 'Fix With Inline Chat'), alignment: CellStatusbarAlignment.Left, - command: OPEN_CELL_FAILURE_ACTIONS_COMMAND_ID, + command: FIX_CELL_ERROR_COMMAND_ID, priority: Number.MAX_SAFE_INTEGER - 1 - }; + }, { + text: `$(sparkle) explain`, + tooltip: localize('notebook.cell.status.explain', 'Explain With Chat'), + alignment: CellStatusbarAlignment.Left, + command: EXPLAIN_CELL_ERROR_COMMAND_ID, + priority: Number.MAX_SAFE_INTEGER - 1 + }]; } - const items = item ? [item] : []; this._currentItemIds = this._notebookViewModel.deltaCellStatusBarItems(this._currentItemIds, [{ handle: this.cell.handle, items }]); } diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts b/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts index d27bbdf4300..7df058a6901 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts @@ -75,7 +75,7 @@ export class EmptyCellEditorHintContribution extends EmptyTextEditorHintContribu } const activeEditor = getNotebookEditorFromEditorPane(this._editorService.activeEditorPane); - if (!activeEditor) { + if (!activeEditor || !activeEditor.isDisposed) { return false; } diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffList.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffList.ts index dd7de68ed52..37a6a4d2b4a 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffList.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffList.ts @@ -6,6 +6,7 @@ import './notebookDiff.css'; import { IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; import * as DOM from '../../../../../base/browser/dom.js'; +import * as domStylesheets from '../../../../../base/browser/domStylesheets.js'; import { IListOptions, IListStyles, isMonacoEditor, IStyleController, MouseController } from '../../../../../base/browser/ui/list/listWidget.js'; import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -481,7 +482,7 @@ export class NotebookTextDiffList extends WorkbenchList .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .view-zones .cell-inner-container { background-color: var(--vscode-diffEditor-removedLineBackground); padding: 8px 0; margin-bottom: 16px; } +/** Cell insert higlight */ +.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.nb-insertHighlight .cell-focus-indicator, +.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.markdown-cell-row.nb-insertHighlight { + background-color: var(--vscode-diffEditor-insertedLineBackground, var(--vscode-diffEditor-insertedTextBackground)) !important; +} + +.notebookOverlay .cell .cell-statusbar-container .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.nb-insertHighlight .cell-focus-indicator .cell-inner-container, +.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.nb-insertHighlight .monaco-editor-background, +.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.nb-insertHighlight .margin-view-overlays, +.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.nb-insertHighlight .cell-statusbar-container { + background-color: var(--vscode-diffEditor-insertedLineBackground, var(--vscode-diffEditor-insertedTextBackground)) !important; +} .monaco-workbench .notebookOverlay .view-zones .cell-editor-part { outline: solid 1px var(--vscode-notebook-cellBorderColor); diff --git a/src/vs/workbench/contrib/notebook/browser/media/notebookChatEditorOverlay.css b/src/vs/workbench/contrib/notebook/browser/media/notebookChatEditorOverlay.css new file mode 100644 index 00000000000..e6b3e30c1bd --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/media/notebookChatEditorOverlay.css @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.notebook-chat-editor-overlay-widget { + position: absolute; + /** Based on chat widget for regular editors **/ + right: 28px; + /** Based on chat widget for regular editors **/ + bottom: 23px; + /** In notebook.css we set this to 22px, we need to revert this to standards **/ + line-height: 1.4em; +} + +/** Copied from src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css **/ +/** Copied until we unify these, for now its separate **/ +.notebook-chat-editor-overlay-widget { + padding: 0px; + color: var(--vscode-button-foreground); + background-color: var(--vscode-button-background); + border-radius: 5px; + border: 1px solid var(--vscode-contrastBorder); + display: flex; + align-items: center; + z-index: 10; +} + +.notebook-chat-editor-overlay-widget .chat-editor-overlay-progress { + display: none; + padding: 0px 5px; + font-size: 12px; +} + +.notebook-chat-editor-overlay-widget.busy .chat-editor-overlay-progress { + display: inherit; +} + +.notebook-chat-editor-overlay-widget .action-item > .action-label { + padding: 5px; + font-size: 12px; +} + +.notebook-chat-editor-overlay-widget .action-item:first-child > .action-label { + padding-left: 9px; +} + +.notebook-chat-editor-overlay-widget .action-item:last-child > .action-label { + padding-right: 9px; +} + +.notebook-chat-editor-overlay-widget.busy .chat-editor-overlay-progress .codicon, +.notebook-chat-editor-overlay-widget .action-item > .action-label.codicon { + color: var(--vscode-button-foreground); +} + +.notebook-chat-editor-overlay-widget .action-item.disabled > .action-label.codicon::before, +.notebook-chat-editor-overlay-widget .action-item.disabled > .action-label.codicon, +.notebook-chat-editor-overlay-widget .action-item.disabled > .action-label, +.notebook-chat-editor-overlay-widget .action-item.disabled > .action-label:hover { + color: var(--vscode-button-foreground); + opacity: 0.7; +} diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts index eda8b6caa1c..50bcbbd1abc 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -130,8 +130,11 @@ import { NotebookMultiDiffEditorInput } from './diff/notebookMultiDiffEditorInpu import { getFormattedMetadataJSON } from '../common/model/notebookCellTextModel.js'; import { INotebookOutlineEntryFactory, NotebookOutlineEntryFactory } from './viewModel/notebookOutlineEntryFactory.js'; import { getFormattedNotebookMetadataJSON } from '../common/model/notebookMetadataTextModel.js'; -import { INotebookOriginalModelReferenceFactory, NotebookChatEditorControllerContrib, NotebookOriginalModelReferenceFactory } from './notebookChatEditController.js'; +import { NotebookChatEditorControllerContrib } from './chatEdit/notebookChatEditController.js'; import { registerNotebookContribution } from './notebookEditorExtensions.js'; +import { INotebookOriginalModelReferenceFactory, NotebookOriginalModelReferenceFactory } from './chatEdit/notebookOriginalModelRefFactory.js'; +import { INotebookModelSynchronizerFactory, NotebookModelSynchronizerFactory } from './chatEdit/notebookSynchronizer.js'; +import { INotebookOriginalCellModelFactory, OriginalNotebookCellModelFactory } from './chatEdit/notebookOriginalCellModelFactory.js'; /*--------------------------------------------------------------------------------------------- */ @@ -880,6 +883,8 @@ registerSingleton(INotebookOutlineEntryFactory, NotebookOutlineEntryFactory, Ins registerNotebookContribution(NotebookChatEditorControllerContrib.ID, NotebookChatEditorControllerContrib); registerSingleton(INotebookOriginalModelReferenceFactory, NotebookOriginalModelReferenceFactory, InstantiationType.Delayed); +registerSingleton(INotebookModelSynchronizerFactory, NotebookModelSynchronizerFactory, InstantiationType.Delayed); +registerSingleton(INotebookOriginalCellModelFactory, OriginalNotebookCellModelFactory, InstantiationType.Delayed); const schemas: IJSONSchemaMap = {}; function isConfigurationPropertySchema(x: IConfigurationPropertySchema | { [path: string]: IConfigurationPropertySchema }): x is IConfigurationPropertySchema { diff --git a/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts b/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts index 72dc16c6631..e035bb1179c 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts @@ -39,6 +39,9 @@ export class NotebookAccessibilityProvider extends Disposable implements IListAc (last: executionUpdate[] | undefined, e: ICellExecutionStateChangedEvent | IExecutionStateChangedEvent) => this.mergeEvents(last, e), 100 )((updates: executionUpdate[]) => { + if (!updates.length) { + return; + } const viewModel = this.viewModel(); if (viewModel) { for (const update of updates) { @@ -52,7 +55,7 @@ export class NotebookAccessibilityProvider extends Disposable implements IListAc if (this.shouldReadCellOutputs(lastUpdate.state)) { const cell = viewModel.getCellByHandle(lastUpdate.cellHandle); if (cell && cell.outputsViewModels.length) { - const text = getAllOutputsText(viewModel.notebookDocument, cell); + const text = getAllOutputsText(viewModel.notebookDocument, cell, true); alert(text); } } @@ -92,7 +95,7 @@ export class NotebookAccessibilityProvider extends Disposable implements IListAc private createItemLabel(executionLabel: string, cellKind: CellKind) { return this.isReplHistory ? - `item${executionLabel}` : + `cell${executionLabel}` : `${cellKind === CellKind.Markup ? 'markdown' : 'code'} cell${executionLabel}`; } diff --git a/src/vs/workbench/contrib/notebook/browser/notebookChatEditController.ts b/src/vs/workbench/contrib/notebook/browser/notebookChatEditController.ts deleted file mode 100644 index d0610590d08..00000000000 --- a/src/vs/workbench/contrib/notebook/browser/notebookChatEditController.ts +++ /dev/null @@ -1,980 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { isEqual } from '../../../../base/common/resources.js'; -import { AsyncReferenceCollection, Disposable, DisposableStore, dispose, IReference, ReferenceCollection, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, autorunWithStore, derived, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; -import { IChatEditingService, WorkingSetEntryState, IModifiedFileEntry, ChatEditingSessionState } from '../../chat/common/chatEditingService.js'; -import { INotebookService } from '../common/notebookService.js'; -import { bufferToStream, VSBuffer } from '../../../../base/common/buffer.js'; -import { NotebookTextModel } from '../common/model/notebookTextModel.js'; -import { INotebookEditor, INotebookEditorContribution } from './notebookBrowser.js'; -import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { raceCancellation, ThrottledDelayer } from '../../../../base/common/async.js'; -import { CellDiffInfo, computeDiff, prettyChanges } from './diff/notebookDiffViewModel.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { INotebookEditorWorkerService } from '../common/services/notebookWorkerService.js'; -import { ChatEditingModifiedFileEntry } from '../../chat/browser/chatEditing/chatEditingModifiedFileEntry.js'; -import { CellEditType, CellKind, CellUri, ICellDto2, ICellReplaceEdit, NotebookData, NotebookSetting } from '../common/notebookCommon.js'; -import { URI } from '../../../../base/common/uri.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { ICodeEditor, IViewZone } from '../../../../editor/browser/editorBrowser.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; -import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js'; -import { ILanguageService } from '../../../../editor/common/languages/language.js'; -import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; -import { themeColorFromId } from '../../../../base/common/themables.js'; -import { RenderOptions, LineSource, renderLines } from '../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; -import { diffAddDecoration, diffWholeLineAddDecoration, diffDeleteDecoration } from '../../../../editor/browser/widget/diffEditor/registrations.contribution.js'; -import { IDocumentDiff } from '../../../../editor/common/diff/documentDiffProvider.js'; -import { ITextModel, TrackedRangeStickiness, MinimapPosition, IModelDeltaDecoration, OverviewRulerLane } from '../../../../editor/common/model.js'; -import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; -import { InlineDecoration, InlineDecorationType } from '../../../../editor/common/viewModel.js'; -import { overviewRulerModifiedForeground, minimapGutterModifiedBackground, overviewRulerAddedForeground, minimapGutterAddedBackground, overviewRulerDeletedForeground, minimapGutterDeletedBackground } from '../../scm/browser/dirtydiffDecorator.js'; -import { Range } from '../../../../editor/common/core/range.js'; -import { NotebookCellTextModel } from '../common/model/notebookCellTextModel.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { EditOperation } from '../../../../editor/common/core/editOperation.js'; -import { INotebookLoggingService } from '../common/notebookLoggingService.js'; -import { TextEdit } from '../../../../editor/common/core/textEdit.js'; -import { Position } from '../../../../editor/common/core/position.js'; -import { DetailedLineRangeMapping, RangeMapping } from '../../../../editor/common/diff/rangeMapping.js'; -import { tokenizeToString } from '../../../../editor/common/languages/textToHtmlTokenizer.js'; -import * as DOM from '../../../../base/browser/dom.js'; -import { createTrustedTypesPolicy } from '../../../../base/browser/trustedTypes.js'; -import { splitLines } from '../../../../base/common/strings.js'; -import { DefaultLineHeight } from './diff/diffElementViewModel.js'; -import { filter } from '../../../../base/common/objects.js'; -import { INotebookEditorModelResolverService } from '../common/notebookEditorModelResolverService.js'; -import { SaveReason } from '../../../common/editor.js'; -import { IChatService } from '../../chat/common/chatService.js'; - - -export const INotebookOriginalModelReferenceFactory = createDecorator('INotebookOriginalModelReferenceFactory'); - -export interface INotebookOriginalModelReferenceFactory { - readonly _serviceBrand: undefined; - getOrCreate(fileEntry: IModifiedFileEntry, viewType: string): Promise>; -} - - -export class NotebookChatEditorControllerContrib extends Disposable implements INotebookEditorContribution { - - public static readonly ID: string = 'workbench.notebook.chatEditorController'; - readonly _serviceBrand: undefined; - constructor( - notebookEditor: INotebookEditor, - @IInstantiationService instantiationService: IInstantiationService, - @IConfigurationService configurationService: IConfigurationService, - - ) { - super(); - if (configurationService.getValue('notebook.experimental.chatEdits')) { - this._register(instantiationService.createInstance(NotebookChatEditorController, notebookEditor)); - } - } -} - - -class NotebookChatEditorController extends Disposable { - private readonly deletedCellOverlayer: NotebookDeletedCellOverlayer; - constructor( - private readonly notebookEditor: INotebookEditor, - @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @INotebookOriginalModelReferenceFactory private readonly originalModelRefFactory: INotebookOriginalModelReferenceFactory, - @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { - super(); - this.deletedCellOverlayer = this._register(instantiationService.createInstance(NotebookDeletedCellOverlayer, notebookEditor)); - const notebookModel = observableFromEvent(this.notebookEditor.onDidChangeModel, e => e); - const entryObs = observableValue('fileentry', undefined); - const notebookDiff = observableValue<{ cellDiff: CellDiffInfo[]; modelVersion: number } | undefined>('cellDiffInfo', undefined); - const originalModel = observableValue('originalModel', undefined); - this._register(toDisposable(() => { - disposeDecorators(); - })); - this._register(autorun(r => { - const session = this._chatEditingService.currentEditingSessionObs.read(r); - const model = notebookModel.read(r); - if (!model || !session) { - return; - } - const entry = session.entries.read(r).find(e => isEqual(e.modifiedURI, model.uri)); - - if (!entry || entry.state.read(r) !== WorkingSetEntryState.Modified) { - disposeDecorators(); - return; - } - // If we have a new entry for the file, then clear old decorators. - // User could be cycling through different edit sessions (Undo Last Edit / Redo Last Edit). - if (entryObs.read(r) && entryObs.read(r) !== entry) { - disposeDecorators(); - } - entryObs.set(entry, undefined); - })); - - this._register(autorunWithStore(async (r, store) => { - const entry = entryObs.read(r); - const model = notebookModel.read(r); - if (!entry || !model) { - return; - } - const notebookSynchronizer = store.add(this.instantiationService.createInstance(NotebookModelSynchronizer, this.notebookEditor, entry, model.viewType)); - notebookDiff.set(undefined, undefined); - await notebookSynchronizer.createSnapshot(); - store.add(notebookSynchronizer.onDidUpdateNotebookModel(e => { - notebookDiff.set(e, undefined); - })); - store.add(notebookSynchronizer.onDidRevert(e => { - if (e) { - notebookDiff.set(undefined, undefined); - disposeDecorators(); - this.deletedCellOverlayer.clear(); - } - })); - store.add(notebookSynchronizer.onDidAccept(() => { - notebookDiff.set(undefined, undefined); - disposeDecorators(); - this.deletedCellOverlayer.clear(); - })); - const result = this._register(await this.originalModelRefFactory.getOrCreate(entry, model.viewType)); - originalModel.set(result.object, undefined); - })); - - const onDidChangeVisibleRanges = observableFromEvent(this.notebookEditor.onDidChangeVisibleRanges, () => this.notebookEditor.visibleRanges); - const decorators = new Map(); - const disposeDecorators = () => { - dispose(Array.from(decorators.values())); - decorators.clear(); - }; - this._register(autorun(r => { - const entry = entryObs.read(r); - const diffInfo = notebookDiff.read(r); - const modified = notebookModel.read(r); - const original = originalModel.read(r); - onDidChangeVisibleRanges.read(r); - - if (!entry || !modified || !original || !diffInfo || diffInfo.modelVersion !== modified.versionId) { - return; - } - - diffInfo.cellDiff.forEach((diff) => { - if (diff.type === 'modified' || diff.type === 'insert') { - const modifiedCell = modified.cells[diff.modifiedCellIndex]; - const originalCellValue = diff.type === 'modified' ? original.cells[diff.originalCellIndex].getValue() : undefined; - const editor = this.notebookEditor.codeEditors.find(([vm,]) => vm.handle === modifiedCell.handle)?.[1]; - if (editor && decorators.get(modifiedCell)?.editor !== editor) { - decorators.get(modifiedCell)?.dispose(); - const decorator = this.instantiationService.createInstance(NotebookCellDiffDecorator, editor, originalCellValue, modifiedCell.cellKind); - decorators.set(modifiedCell, decorator); - this._register(editor.onDidDispose(() => { - decorator.dispose(); - if (decorators.get(modifiedCell) === decorator) { - decorators.set(modifiedCell, decorator); - } - })); - } - } - }); - })); - this._register(autorun(r => { - const entry = entryObs.read(r); - const diffInfo = notebookDiff.read(r); - const modified = notebookModel.read(r); - const original = originalModel.read(r); - if (!entry || !modified || !original || (diffInfo && diffInfo.modelVersion !== modified.versionId)) { - return; - } - if (!diffInfo) { - // User reverted or accepted the changes, hence original === modified. - this.deletedCellOverlayer.clear(); - return; - } - this.deletedCellOverlayer.createNecessaryOverlays(diffInfo.cellDiff, original); - })); - } -} - - -class NotebookCellDiffDecorator extends DisposableStore { - private readonly _decorations = this.editor.createDecorationsCollection(); - private _viewZones: string[] = []; - private readonly throttledDecorator = new ThrottledDelayer(100); - - constructor( - public readonly editor: ICodeEditor, - private readonly originalCellValue: string | undefined, - private readonly cellKind: CellKind, - @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @IModelService private readonly modelService: IModelService, - @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, - @ILanguageService private readonly _languageService: ILanguageService, - - ) { - super(); - this.add(this.editor.onDidChangeModel(() => this.update())); - this.add(this.editor.onDidChangeConfiguration((e) => { - if (e.hasChanged(EditorOption.fontInfo) || e.hasChanged(EditorOption.lineHeight)) { - this.update(); - } - })); - - const shouldBeReadOnly = derived(this, r => { - const value = this._chatEditingService.currentEditingSessionObs.read(r); - if (!value || value.state.read(r) !== ChatEditingSessionState.StreamingEdits) { - return false; - } - return value.entries.read(r).some(e => isEqual(e.modifiedURI, this.editor.getModel()?.uri)); - }); - - - let actualReadonly: boolean | undefined; - let actualDeco: 'off' | 'editable' | 'on' | undefined; - - this.add(autorun(r => { - const value = shouldBeReadOnly.read(r); - if (value) { - actualReadonly ??= this.editor.getOption(EditorOption.readOnly); - actualDeco ??= this.editor.getOption(EditorOption.renderValidationDecorations); - - this.editor.updateOptions({ - readOnly: true, - renderValidationDecorations: 'off' - }); - } else { - if (actualReadonly !== undefined && actualDeco !== undefined) { - this.editor.updateOptions({ - readOnly: actualReadonly, - renderValidationDecorations: actualDeco - }); - actualReadonly = undefined; - actualDeco = undefined; - } - } - })); - this.update(); - } - - override dispose(): void { - this._clearRendering(); - super.dispose(); - } - - public update(): void { - this.throttledDecorator.trigger(() => this._updateImpl()); - } - - private async _updateImpl() { - if (this.isDisposed) { - return; - } - if (!this.editor.hasModel()) { - this._clearRendering(); - return; - } - if (this.editor.getOption(EditorOption.inDiffEditor)) { - this._clearRendering(); - return; - } - const model = this.editor.getModel(); - if (!model) { - this._clearRendering(); - return; - } - - const version = model.getVersionId(); - const originalModel = this.getOrCreateOriginalModel(); - const diff = originalModel ? await this.computeDiff() : undefined; - if (this.isDisposed) { - return; - } - - if ((originalModel && !diff) || model !== this.editor.getModel() || this.editor.getModel()?.getVersionId() !== version) { - this._clearRendering(); - } - - if (diff && originalModel) { - this._updateWithDiff(originalModel, diff); - } else { - const edit = TextEdit.insert(new Position(0, 0), model.getValue()); - const rangeMapping = RangeMapping.fromEdit(edit); - const insertDiff: IDocumentDiff = { - identical: false, - moves: [], - quitEarly: false, - changes: [DetailedLineRangeMapping.fromRangeMappings(rangeMapping)], - }; - this._updateWithDiff(undefined, insertDiff); - } - } - - private _clearRendering() { - this.editor.changeViewZones((viewZoneChangeAccessor) => { - for (const id of this._viewZones) { - viewZoneChangeAccessor.removeZone(id); - } - }); - this._viewZones = []; - this._decorations.clear(); - } - - private _originalModel?: ITextModel; - private getOrCreateOriginalModel() { - if (this._originalModel) { - return this._originalModel; - } - if (!this.originalCellValue) { - return; - } - const model = this.editor.getModel(); - if (!model) { - return; - } - const cellUri = model.uri; - const languageId = model.getLanguageId(); - - const scheme = `${CellUri.scheme}-chat-edit`; - const originalCellUri = URI.from({ scheme, fragment: cellUri.fragment, path: cellUri.path }); - const languageSelection = this._languageService.getLanguageIdByLanguageName(languageId) ? this._languageService.createById(languageId) : this.cellKind === CellKind.Markup ? this._languageService.createById('markdown') : null; - return this._originalModel = this.add(this.modelService.createModel(this.originalCellValue, languageSelection, originalCellUri)); - } - private async computeDiff() { - const model = this.editor.getModel(); - if (!model) { - return; - } - const originalModel = this.getOrCreateOriginalModel(); - if (!originalModel) { - return; - } - - return this._editorWorkerService.computeDiff( - originalModel.uri, - model.uri, - { computeMoves: true, ignoreTrimWhitespace: false, maxComputationTimeMs: Number.MAX_SAFE_INTEGER }, - 'advanced' - ); - } - - private _updateWithDiff(originalModel: ITextModel | undefined, diff: IDocumentDiff): void { - const chatDiffAddDecoration = ModelDecorationOptions.createDynamic({ - ...diffAddDecoration, - stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges - }); - const chatDiffWholeLineAddDecoration = ModelDecorationOptions.createDynamic({ - ...diffWholeLineAddDecoration, - stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, - }); - const createOverviewDecoration = (overviewRulerColor: string, minimapColor: string) => { - return ModelDecorationOptions.createDynamic({ - description: 'chat-editing-decoration', - overviewRuler: { color: themeColorFromId(overviewRulerColor), position: OverviewRulerLane.Left }, - minimap: { color: themeColorFromId(minimapColor), position: MinimapPosition.Gutter }, - }); - }; - const modifiedDecoration = createOverviewDecoration(overviewRulerModifiedForeground, minimapGutterModifiedBackground); - const addedDecoration = createOverviewDecoration(overviewRulerAddedForeground, minimapGutterAddedBackground); - const deletedDecoration = createOverviewDecoration(overviewRulerDeletedForeground, minimapGutterDeletedBackground); - - this.editor.changeViewZones((viewZoneChangeAccessor) => { - for (const id of this._viewZones) { - viewZoneChangeAccessor.removeZone(id); - } - this._viewZones = []; - const modifiedDecorations: IModelDeltaDecoration[] = []; - const mightContainNonBasicASCII = originalModel?.mightContainNonBasicASCII(); - const mightContainRTL = originalModel?.mightContainRTL(); - const renderOptions = RenderOptions.fromEditor(this.editor); - - for (const diffEntry of diff.changes) { - const originalRange = diffEntry.original; - if (originalModel) { - originalModel.tokenization.forceTokenization(Math.max(1, originalRange.endLineNumberExclusive - 1)); - } - const source = new LineSource( - (originalRange.length && originalModel) ? originalRange.mapToLineArray(l => originalModel.tokenization.getLineTokens(l)) : [], - [], - mightContainNonBasicASCII, - mightContainRTL, - ); - const decorations: InlineDecoration[] = []; - for (const i of diffEntry.innerChanges || []) { - decorations.push(new InlineDecoration( - i.originalRange.delta(-(diffEntry.original.startLineNumber - 1)), - diffDeleteDecoration.className!, - InlineDecorationType.Regular - )); - modifiedDecorations.push({ - range: i.modifiedRange, options: chatDiffAddDecoration - }); - } - if (!diffEntry.modified.isEmpty) { - modifiedDecorations.push({ - range: diffEntry.modified.toInclusiveRange()!, options: chatDiffWholeLineAddDecoration - }); - } - - if (diffEntry.original.isEmpty) { - // insertion - modifiedDecorations.push({ - range: diffEntry.modified.toInclusiveRange()!, - options: addedDecoration - }); - } else if (diffEntry.modified.isEmpty) { - // deletion - modifiedDecorations.push({ - range: new Range(diffEntry.modified.startLineNumber - 1, 1, diffEntry.modified.startLineNumber, 1), - options: deletedDecoration - }); - } else { - // modification - modifiedDecorations.push({ - range: diffEntry.modified.toInclusiveRange()!, - options: modifiedDecoration - }); - } - const domNode = document.createElement('div'); - domNode.className = 'chat-editing-original-zone view-lines line-delete monaco-mouse-cursor-text'; - const result = renderLines(source, renderOptions, decorations, domNode); - - const isCreatedContent = decorations.length === 1 && decorations[0].range.isEmpty() && decorations[0].range.startLineNumber === 1; - if (!isCreatedContent) { - const viewZoneData: IViewZone = { - afterLineNumber: diffEntry.modified.startLineNumber - 1, - heightInLines: result.heightInLines, - domNode, - ordinal: 50000 + 2 // more than https://github.com/microsoft/vscode/blob/bf52a5cfb2c75a7327c9adeaefbddc06d529dcad/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts#L42 - }; - - this._viewZones.push(viewZoneChangeAccessor.addZone(viewZoneData)); - } - } - - this._decorations.set(modifiedDecorations); - }); - } -} - - -class NotebookModelSynchronizer extends Disposable { - private readonly throttledUpdateNotebookModel = new ThrottledDelayer(200); - private readonly _onDidUpdateNotebookModel = this._register(new Emitter<{ cellDiff: CellDiffInfo[]; modelVersion: number }>); - public readonly onDidUpdateNotebookModel = this._onDidUpdateNotebookModel.event; - private readonly _onDidRevert = this._register(new Emitter()); - public readonly onDidRevert = this._onDidRevert.event; - private readonly _onDidAccept = this._register(new Emitter()); - public readonly onDidAccept = this._onDidAccept.event; - private snapshot?: { bytes: VSBuffer; dirty: boolean }; - private isEditFromUs: boolean = false; - constructor( - private readonly notebookEditor: INotebookEditor, - public readonly entry: IModifiedFileEntry, - private readonly viewType: string, - @INotebookService private readonly notebookService: INotebookService, - @IChatService chatService: IChatService, - @INotebookLoggingService private readonly logService: INotebookLoggingService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @INotebookEditorWorkerService private readonly notebookEditorWorkerService: INotebookEditorWorkerService, - @INotebookEditorModelResolverService private readonly notebookModelResolverService: INotebookEditorModelResolverService, - ) { - super(); - - this._register(chatService.onDidPerformUserAction(async e => { - if (e.action.kind === 'chatEditingSessionAction' && !e.action.hasRemainingEdits && isEqual(e.action.uri, entry.modifiedURI)) { - if (e.action.outcome === 'accepted') { - await this.accept(); - await this.createSnapshot(); - this._onDidAccept.fire(); - } - else if (e.action.outcome === 'rejected') { - if (await this.revert()) { - this._onDidRevert.fire(true); - } - } - } - })); - - const cancellationTokenStore = this._register(new DisposableStore()); - let cancellationToken = cancellationTokenStore.add(new CancellationTokenSource()); - const updateNotebookModel = (entry: IModifiedFileEntry, viewType: string, token: CancellationToken) => { - this.throttledUpdateNotebookModel.trigger(() => this.updateNotebookModel(entry, viewType, token)); - }; - const modelObs = observableFromEvent(notebookEditor.onDidChangeModel, e => e); - const modifiedModel = (entry as ChatEditingModifiedFileEntry).modifiedModel; - this._register(modifiedModel.onDidChangeContent(async () => { - cancellationTokenStore.clear(); - if (!modifiedModel.isDisposed() && !entry.originalModel.isDisposed() && modifiedModel.getValue() === entry.originalModel.getValue()) { - if (await this.revert()) { - this._onDidRevert.fire(true); - } - return; - } - cancellationToken = cancellationTokenStore.add(new CancellationTokenSource()); - updateNotebookModel(entry, viewType, cancellationToken.token); - })); - this._register(autorunWithStore((r, store) => { - const model = modelObs.read(r); - if (model) { - store.add(model.onDidChangeContent(() => { - // Track changes from the user. - if (!this.isEditFromUs && this.snapshot) { - this.snapshot.dirty = true; - } - })); - } - })); - - updateNotebookModel(entry, viewType, cancellationToken.token); - - - } - - public async createSnapshot() { - const model = this.notebookEditor.textModel; - if (!model) { - return; - } - const [serializer, ref] = await Promise.all([ - this.getNotebookSerializer(), - this.notebookModelResolverService.resolve(this.notebookEditor.textModel.uri) - ]); - - try { - const data: NotebookData = { - metadata: filter(model.metadata, key => !serializer.options.transientDocumentMetadata[key]), - cells: [], - }; - - let outputSize = 0; - for (const cell of model.cells) { - const cellData: ICellDto2 = { - cellKind: cell.cellKind, - language: cell.language, - mime: cell.mime, - source: cell.getValue(), - outputs: [], - internalMetadata: cell.internalMetadata - }; - - const outputSizeLimit = this.configurationService.getValue(NotebookSetting.outputBackupSizeLimit) * 1024; - if (outputSizeLimit > 0) { - cell.outputs.forEach(output => { - output.outputs.forEach(item => { - outputSize += item.data.byteLength; - }); - }); - if (outputSize > outputSizeLimit) { - return; - } - } - - cellData.outputs = !serializer.options.transientOutputs ? cell.outputs : []; - cellData.metadata = filter(cell.metadata, key => !serializer.options.transientCellMetadata[key]); - - data.cells.push(cellData); - } - - const bytes = await serializer.notebookToData(data); - this.snapshot = { bytes, dirty: ref.object.isDirty() }; - } finally { - ref.dispose(); - } - } - - private async revert(): Promise { - if (!this.snapshot) { - return false; - } - await this.updateNotebook(this.snapshot.bytes, this.snapshot.dirty); - return true; - } - - private async updateNotebook(bytes: VSBuffer, dirty: boolean) { - if (!this.notebookEditor.textModel) { - return; - } - - const ref = await this.notebookModelResolverService.resolve(this.notebookEditor.textModel.uri); - try { - const serializer = await this.getNotebookSerializer(); - const data = await serializer.dataToNotebook(bytes); - this.notebookEditor.textModel.reset(data.cells, data.metadata, serializer.options); - - if (!dirty) { - // save the file after discarding so that the dirty indicator goes away - // and so that an intermediate saved state gets reverted - // await this.notebookEditor.textModel.save({ reason: SaveReason.EXPLICIT }); - await ref.object.save({ reason: SaveReason.EXPLICIT }); - } - } finally { - ref.dispose(); - } - } - - private async accept() { - const modifiedModel = (this.entry as ChatEditingModifiedFileEntry).modifiedModel; - const content = modifiedModel.getValue(); - await this.updateNotebook(VSBuffer.fromString(content), false); - } - - async getNotebookSerializer() { - const info = await this.notebookService.withNotebookDataProvider(this.viewType); - return info.serializer; - } - - private async updateNotebookModel(entry: IModifiedFileEntry, viewType: string, token: CancellationToken) { - const modifiedModelVersion = (entry as ChatEditingModifiedFileEntry).modifiedModel.getVersionId(); - const original = this.notebookEditor.textModel; - const originalModelVersion = original?.versionId ?? 0; - const model = await this.getModifiedModelForDiff(entry, viewType, token); - if (!model || token.isCancellationRequested || !original) { - return; - } - const cellDiffInfo = (await this.computeDiff(original, model, token))?.cellDiffInfo; - const currentVersion = (entry as ChatEditingModifiedFileEntry).modifiedModel.getVersionId(); - if (!cellDiffInfo || token.isCancellationRequested || currentVersion !== modifiedModelVersion || originalModelVersion !== original.versionId) { - return; - } - if (cellDiffInfo.every(d => d.type === 'unchanged')) { - return; - } - - // All edits from here on are from us. - this.isEditFromUs = true; - try { - const edits: ICellReplaceEdit[] = []; - const mappings = new Map(); - - // First Delete. - const deletedIndexes: number[] = []; - cellDiffInfo.reverse().forEach(diff => { - if (diff.type === 'delete') { - deletedIndexes.push(diff.originalCellIndex); - edits.push({ - editType: CellEditType.Replace, - index: diff.originalCellIndex, - cells: [], - count: 1 - }); - } - }); - if (edits.length) { - original.applyEdits(edits, true, undefined, () => undefined, undefined, false); - edits.length = 0; - } - - // Next insert. - cellDiffInfo.reverse().forEach(diff => { - if (diff.type === 'modified' || diff.type === 'unchanged') { - mappings.set(diff.modifiedCellIndex, diff.originalCellIndex); - } - if (diff.type === 'insert') { - const originalIndex = mappings.get(diff.modifiedCellIndex - 1) ?? 0; - mappings.set(diff.modifiedCellIndex, originalIndex); - const cell = model.cells[diff.modifiedCellIndex]; - const newCell: ICellDto2 = - { - source: cell.getValue(), - cellKind: cell.cellKind, - language: cell.language, - outputs: cell.outputs.map(output => output.asDto()), - mime: cell.mime, - metadata: cell.metadata, - collapseState: cell.collapseState, - internalMetadata: cell.internalMetadata - }; - edits.push({ - editType: CellEditType.Replace, - index: originalIndex + 1, - cells: [newCell], - count: 0 - }); - } - }); - if (edits.length) { - original.applyEdits(edits, true, undefined, () => undefined, undefined, false); - edits.length = 0; - } - - // Finally update - cellDiffInfo.forEach(diff => { - if (diff.type === 'modified') { - const cell = original.cells[diff.originalCellIndex]; - const textModel = cell.textModel; - if (textModel) { - const newText = model.cells[diff.modifiedCellIndex].getValue(); - textModel.pushEditOperations(null, [ - EditOperation.replace(textModel.getFullModelRange(), newText) - ], () => null); - } - } - }); - - if (edits.length) { - original.applyEdits(edits, true, undefined, () => undefined, undefined, false); - } - this._onDidRevert.fire(false); - this._onDidUpdateNotebookModel.fire({ cellDiff: cellDiffInfo, modelVersion: original.versionId }); - } - finally { - this.isEditFromUs = false; - } - } - private previousUriOfModelForDiff?: URI; - - private async getModifiedModelForDiff(entry: IModifiedFileEntry, viewType: string, token: CancellationToken): Promise { - const text = (entry as ChatEditingModifiedFileEntry).modifiedModel.getValue(); - const bytes = VSBuffer.fromString(text); - const uri = entry.modifiedURI.with({ scheme: `NotebookChatEditorController.modifiedScheme${Date.now().toString()}` }); - const stream = bufferToStream(bytes); - if (this.previousUriOfModelForDiff) { - this.notebookService.getNotebookTextModel(this.previousUriOfModelForDiff)?.dispose(); - } - this.previousUriOfModelForDiff = uri; - try { - const model = await this.notebookService.createNotebookTextModel(viewType, uri, stream); - if (token.isCancellationRequested) { - model.dispose(); - return; - } - this._register(model); - return model; - } catch (ex) { - this.logService.warn('NotebookChatEdit', `Failed to deserialize Notebook for ${uri.toString}, ${ex.message}`); - this.logService.debug('NotebookChatEdit', ex.toString()); - return; - } - } - - async computeDiff(original: NotebookTextModel, modified: NotebookTextModel, token: CancellationToken) { - const diffResult = await raceCancellation(this.notebookEditorWorkerService.computeDiff(original.uri, modified.uri), token); - if (!diffResult || token.isCancellationRequested) { - // after await the editor might be disposed. - return; - } - - prettyChanges(original, modified, diffResult.cellsDiff); - - return computeDiff(original, modified, diffResult); - } -} - -const ttPolicy = createTrustedTypesPolicy('notebookChatEditController', { createHTML: value => value }); - -class NotebookDeletedCellOverlayer extends Disposable { - private readonly zoneRemover = this._register(new DisposableStore()); - private readonly createdViewZones = new Map(); - constructor( - private readonly _notebookEditor: INotebookEditor, - @ILanguageService private readonly languageService: ILanguageService, - ) { - super(); - } - - - public createNecessaryOverlays(diffInfo: CellDiffInfo[], original: NotebookTextModel): void { - this.clear(); - - let currentIndex = 0; - const deletedCellsToRender: { cells: NotebookCellTextModel[]; index: number } = { cells: [], index: 0 }; - diffInfo.forEach(diff => { - if (diff.type === 'delete') { - const deletedCell = original.cells[diff.originalCellIndex]; - if (deletedCell) { - deletedCellsToRender.cells.push(deletedCell); - deletedCellsToRender.index = currentIndex; - } - } else { - if (deletedCellsToRender.cells.length) { - this._createWidget(deletedCellsToRender.index + 1, deletedCellsToRender.cells); - deletedCellsToRender.cells.length = 0; - } - currentIndex = diff.modifiedCellIndex; - } - }); - if (deletedCellsToRender.cells.length) { - this._createWidget(deletedCellsToRender.index + 1, deletedCellsToRender.cells); - } - } - - public clear() { - this.zoneRemover.clear(); - } - - - private _createWidget(index: number, cells: NotebookCellTextModel[]) { - this._createWidgetImpl(index, cells); - } - private async _createWidgetImpl(index: number, cells: NotebookCellTextModel[]) { - const rootContainer = document.createElement('div'); - const widgets = cells.map(cell => new NotebookDeletedCellWidget(this._notebookEditor, cell.getValue(), cell.language, rootContainer, this.languageService)); - const heights = await Promise.all(widgets.map(w => w.render())); - const totalHeight = heights.reduce((prev, curr) => prev + curr, 0); - - this._notebookEditor.changeViewZones(accessor => { - const notebookViewZone = { - afterModelPosition: index, - heightInPx: totalHeight, - domNode: rootContainer - }; - - const id = accessor.addZone(notebookViewZone); - accessor.layoutZone(id); - this.createdViewZones.set(index, id); - this.zoneRemover.add(toDisposable(() => { - if (this.createdViewZones.get(index) === id) { - this.createdViewZones.delete(index); - } - if (!this._notebookEditor.isDisposed) { - this._notebookEditor.changeViewZones(accessor => { - accessor.removeZone(id); - dispose(widgets); - }); - } - })); - }); - } - -} - - -export class OriginalNotebookModelReferenceCollection extends ReferenceCollection> { - private readonly modelsToDispose = new Set(); - constructor(@INotebookService private readonly notebookService: INotebookService) { - super(); - } - - protected override async createReferencedObject(key: string, fileEntry: IModifiedFileEntry, viewType: string): Promise { - this.modelsToDispose.delete(key); - const uri = fileEntry.originalURI; - const model = this.notebookService.getNotebookTextModel(uri); - if (model) { - return model; - } - const bytes = VSBuffer.fromString(fileEntry.originalModel.getValue()); - const stream = bufferToStream(bytes); - - return this.notebookService.createNotebookTextModel(viewType, uri, stream); - } - protected override destroyReferencedObject(key: string, modelPromise: Promise): void { - this.modelsToDispose.add(key); - - (async () => { - try { - const model = await modelPromise; - - if (!this.modelsToDispose.has(key)) { - // return if model has been acquired again meanwhile - return; - } - - // Finally we can dispose the model - model.dispose(); - } catch (error) { - // ignore - } finally { - this.modelsToDispose.delete(key); // Untrack as being disposed - } - })(); - } -} - -class NotebookDeletedCellWidget extends Disposable { - private readonly container: HTMLElement; - constructor( - private readonly _notebookEditor: INotebookEditor, - // private readonly _index: number, - private readonly code: string, - private readonly language: string, - container: HTMLElement, - @ILanguageService private readonly languageService: ILanguageService, - ) { - super(); - this.container = DOM.append(container, document.createElement('div')); - this._register(toDisposable(() => { - container.removeChild(this.container); - })); - } - - public async render() { - const code = this.code; - const languageId = this.language; - const codeHtml = await tokenizeToString(this.languageService, code, languageId); - - // const colorMap = this.getDefaultColorMap(); - const fontInfo = this._notebookEditor.getBaseCellEditorOptions(languageId).value; - const fontFamilyVar = '--notebook-editor-font-family'; - const fontSizeVar = '--notebook-editor-font-size'; - const fontWeightVar = '--notebook-editor-font-weight'; - // If we have any editors, then use left layout of one of those. - const editor = this._notebookEditor.codeEditors.map(c => c[1]).find(c => c); - const layoutInfo = editor?.getOptions().get(EditorOption.layoutInfo); - - const style = `` - + `font-family: var(${fontFamilyVar});` - + `font-weight: var(${fontWeightVar});` - + `font-size: var(${fontSizeVar});` - + fontInfo.lineHeight ? `line-height: ${fontInfo.lineHeight}px;` : '' - + layoutInfo?.contentLeft ? `margin-left: ${layoutInfo}px;` : '' - + `white-space: pre;`; - - - - const rootContainer = this.container; - rootContainer.classList.add('code-cell-row'); - const container = DOM.append(rootContainer, DOM.$('.cell-inner-container')); - const focusIndicatorLeft = DOM.append(container, DOM.$('.cell-focus-indicator.cell-focus-indicator-side.cell-focus-indicator-left')); - const cellContainer = DOM.append(container, DOM.$('.cell.code')); - DOM.append(focusIndicatorLeft, DOM.$('div.execution-count-label')); - const editorPart = DOM.append(cellContainer, DOM.$('.cell-editor-part')); - let editorContainer = DOM.append(editorPart, DOM.$('.cell-editor-container')); - editorContainer = DOM.append(editorContainer, DOM.$('.code', { style })); - if (fontInfo.fontFamily) { - editorContainer.style.setProperty(fontFamilyVar, fontInfo.fontFamily); - } - if (fontInfo.fontSize) { - editorContainer.style.setProperty(fontSizeVar, `${fontInfo.fontSize}px`); - } - if (fontInfo.fontWeight) { - editorContainer.style.setProperty(fontWeightVar, fontInfo.fontWeight); - } - editorContainer.innerHTML = (ttPolicy?.createHTML(codeHtml) || codeHtml) as string; - - const lineCount = splitLines(code).length; - const height = (lineCount * (fontInfo.lineHeight || DefaultLineHeight)) + 12 + 12; // We have 12px top and bottom in generated code HTML; - const totalHeight = height + 16; - - return totalHeight; - } -} - -export class NotebookOriginalModelReferenceFactory implements INotebookOriginalModelReferenceFactory { - readonly _serviceBrand: undefined; - private _resourceModelCollection: OriginalNotebookModelReferenceCollection & ReferenceCollection> /* TS Fail */ | undefined = undefined; - private get resourceModelCollection() { - if (!this._resourceModelCollection) { - this._resourceModelCollection = this.instantiationService.createInstance(OriginalNotebookModelReferenceCollection); - } - - return this._resourceModelCollection; - } - - private _asyncModelCollection: AsyncReferenceCollection | undefined = undefined; - private get asyncModelCollection() { - if (!this._asyncModelCollection) { - this._asyncModelCollection = new AsyncReferenceCollection(this.resourceModelCollection); - } - - return this._asyncModelCollection; - } - - constructor(@IInstantiationService private readonly instantiationService: IInstantiationService) { - } - - getOrCreate(fileEntry: IModifiedFileEntry, viewType: string): Promise> { - return this.asyncModelCollection.acquire(fileEntry.originalURI.toString(), fileEntry, viewType); - } -} - diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 8b863d6051c..b08b816b95a 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -18,7 +18,9 @@ import './media/notebookEditorStickyScroll.css'; import './media/notebookKernelActionViewItem.css'; import './media/notebookOutline.css'; import './media/notebookChatEditController.css'; +import './media/notebookChatEditorOverlay.css'; import * as DOM from '../../../../base/browser/dom.js'; +import * as domStylesheets from '../../../../base/browser/domStylesheets.js'; import { IMouseWheelEvent, StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; import { IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; import { mainWindow } from '../../../../base/browser/window.js'; @@ -637,7 +639,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD } private _createLayoutStyles(): void { - this._styleElement = DOM.createStyleSheet(this._body); + this._styleElement = domStylesheets.createStyleSheet(this._body); const { cellRightMargin, cellTopMargin, diff --git a/src/vs/workbench/contrib/notebook/browser/replEditorAccessibleView.ts b/src/vs/workbench/contrib/notebook/browser/replEditorAccessibleView.ts new file mode 100644 index 00000000000..98184cdfe3a --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/replEditorAccessibleView.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { AccessibleViewType, AccessibleContentProvider, AccessibleViewProviderId } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplentation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { isReplEditorControl } from '../../replNotebook/browser/replEditor.js'; +import { IS_COMPOSITE_NOTEBOOK, NOTEBOOK_CELL_LIST_FOCUSED } from '../common/notebookContextKeys.js'; +import { getAllOutputsText } from './viewModel/cellOutputTextHelper.js'; + +/** + * The REPL input is already accessible, so we can show a view for the most recent execution output. + */ +export class ReplEditorAccessibleView implements IAccessibleViewImplentation { + readonly priority = 100; + readonly name = 'replEditorInput'; + readonly type = AccessibleViewType.View; + readonly when = ContextKeyExpr.and(IS_COMPOSITE_NOTEBOOK, NOTEBOOK_CELL_LIST_FOCUSED.negate()); + getProvider(accessor: ServicesAccessor) { + const editorService = accessor.get(IEditorService); + return getAccessibleOutputProvider(editorService); + } +} + +export function getAccessibleOutputProvider(editorService: IEditorService) { + const editorControl = editorService.activeEditorPane?.getControl(); + + if (editorControl && isReplEditorControl(editorControl) && editorControl.notebookEditor) { + const notebookEditor = editorControl.notebookEditor; + const viewModel = notebookEditor?.getViewModel(); + if (notebookEditor && viewModel) { + // last cell of the viewmodel is the last cell history + const lastCellIndex = viewModel.length - 1; + if (lastCellIndex >= 0) { + const cell = viewModel.viewCells[lastCellIndex]; + const outputContent = getAllOutputsText(viewModel.notebookDocument, cell); + + if (outputContent) { + return new AccessibleContentProvider( + AccessibleViewProviderId.Notebook, + { type: AccessibleViewType.View }, + () => { return outputContent; }, + () => { + editorControl.activeCodeEditor?.focus(); + }, + AccessibilityVerbositySettingId.ReplEditor, + ); + } + } + } + } + + return; +} diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellContextKeys.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellContextKeys.ts index 833605ad37b..20f7a85dfc4 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellContextKeys.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellContextKeys.ts @@ -103,7 +103,7 @@ export class CellContextKeyManager extends Disposable { if (element instanceof CodeCellViewModel) { this.elementDisposables.add(element.onDidChangeOutputs(() => this.updateForOutputs())); this.elementDisposables.add(autorun(reader => { - this.cellHasErrorDiagnostics.set(!!reader.readObservable(element.excecutionError)); + this.cellHasErrorDiagnostics.set(!!reader.readObservable(element.executionError)); })); } diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts index 584b6c9cb32..fdeaf59ad84 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../../base/browser/dom.js'; +import * as domStylesheetsJs from '../../../../../base/browser/domStylesheets.js'; import { IMouseWheelEvent } from '../../../../../base/browser/mouseEvent.js'; import { IListRenderer, IListVirtualDelegate, ListError } from '../../../../../base/browser/ui/list/list.js'; import { IListStyles, IStyleController } from '../../../../../base/browser/ui/list/listWidget.js'; @@ -1316,7 +1317,7 @@ export class NotebookCellList extends WorkbenchList implements ID override style(styles: IListStyles) { const selectorSuffix = this.view.domId; if (!this.styleElement) { - this.styleElement = DOM.createStyleSheet(this.view.domNode); + this.styleElement = domStylesheetsJs.createStyleSheet(this.view.domNode); } const suffix = selectorSuffix && `.${selectorSuffix}`; const content: string[] = []; diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/cellOutputTextHelper.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/cellOutputTextHelper.ts index 3f540054100..3eb68a713a5 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/cellOutputTextHelper.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/cellOutputTextHelper.ts @@ -15,7 +15,7 @@ interface Error { stack?: string; } -export function getAllOutputsText(notebook: NotebookTextModel, viewCell: ICellViewModel): string { +export function getAllOutputsText(notebook: NotebookTextModel, viewCell: ICellViewModel, shortErrors: boolean = false): string { const outputText: string[] = []; for (let i = 0; i < viewCell.outputsViewModels.length; i++) { const outputViewModel = viewCell.outputsViewModels[i]; @@ -40,7 +40,7 @@ export function getAllOutputsText(notebook: NotebookTextModel, viewCell: ICellVi i += count - 1; } } else { - text = getOutputText(mimeType, buffer); + text = getOutputText(mimeType, buffer, shortErrors); } outputText.push(text); @@ -80,7 +80,7 @@ export function getOutputStreamText(output: ICellOutputViewModel): { text: strin const decoder = new TextDecoder(); -export function getOutputText(mimeType: string, buffer: IOutputItemDto) { +export function getOutputText(mimeType: string, buffer: IOutputItemDto, shortError: boolean = false): string { let text = `${mimeType}`; // default in case we can't get the text value for some reason. const charLimit = 100000; @@ -92,10 +92,10 @@ export function getOutputText(mimeType: string, buffer: IOutputItemDto) { text = text.replace(/\\u001b\[[0-9;]*m/gi, ''); try { const error = JSON.parse(text) as Error; - if (error.stack) { - text = error.stack; - } else { + if (!error.stack || shortError) { text = `${error.name}: ${error.message}`; + } else { + text = error.stack; } } catch { // just use raw text diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts index 6cdafcd2185..64fe9c1ce47 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts @@ -5,14 +5,13 @@ import { Emitter, Event, PauseableEmitter } from '../../../../../base/common/event.js'; import { dispose } from '../../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../../base/common/observable.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; import * as UUID from '../../../../../base/common/uuid.js'; import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import * as editorCommon from '../../../../../editor/common/editorCommon.js'; import { PrefixSumComputer } from '../../../../../editor/common/model/prefixSumComputer.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IUndoRedoService } from '../../../../../platform/undoRedo/common/undoRedo.js'; import { CellEditState, CellFindMatch, CellLayoutState, CodeCellLayoutChangeEvent, CodeCellLayoutInfo, ICellOutputViewModel, ICellViewModel } from '../notebookBrowser.js'; import { NotebookOptionsChangeEvent } from '../notebookOptions.js'; @@ -135,7 +134,11 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod return this._outputViewModels; } - readonly excecutionError = observableValue('excecutionError', undefined); + get executionError(): IObservable { + return this._executionError; + } + + private readonly _executionError = observableValue('excecutionError', undefined); constructor( viewType: string, @@ -146,8 +149,7 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod @INotebookService private readonly _notebookService: INotebookService, @ITextModelService modelService: ITextModelService, @IUndoRedoService undoRedoService: IUndoRedoService, - @ICodeEditorService codeEditorService: ICodeEditorService, - @IInstantiationService instantiationService: IInstantiationService + @ICodeEditorService codeEditorService: ICodeEditorService ) { super(viewType, model, UUID.generateUuid(), viewContext, configurationService, modelService, undoRedoService, codeEditorService); this._outputViewModels = this.model.outputs.map(output => new CellOutputViewModel(this, output, this._notebookService)); @@ -170,6 +172,9 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod if (outputLayoutChange) { this.layoutChange({ outputHeight: true }, 'CodeCellViewModel#model.onDidChangeOutputs'); } + if (!this._outputCollection.length) { + this._executionError.set(undefined, undefined); + } dispose(removedOutputs); })); @@ -200,9 +205,14 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod updateExecutionState(e: ICellExecutionStateChangedEvent) { if (e.changed) { + this._executionError.set(undefined, undefined); this._onDidStartExecution.fire(e); } else { this._onDidStopExecution.fire(e); + if (this.internalMetadata.lastRunSuccess === false && this.internalMetadata.error) { + const metadata = this.internalMetadata; + this._executionError.set(metadata.error, undefined); + } } } diff --git a/src/vs/workbench/contrib/notebook/common/notebookExecutionStateService.ts b/src/vs/workbench/contrib/notebook/common/notebookExecutionStateService.ts index 8ea8fb79644..8ad13f68f51 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookExecutionStateService.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookExecutionStateService.ts @@ -21,12 +21,29 @@ export interface ICellExecutionStateUpdate { isPaused?: boolean; } +export interface ICellErrorStackFrame { + /** + * The location of this stack frame. This should be provided as a URI if the + * location of the call frame can be accessed by the editor. + */ + readonly uri?: UriComponents; + + readonly location?: IRange; + + /** + * The name of the stack frame, typically a method or function name. + */ + readonly label: string; +} + export interface ICellExecutionError { + name: string; message: string; - stack: string | undefined; + stack: string | ICellErrorStackFrame[] | undefined; uri: UriComponents; location: IRange | undefined; } + export interface ICellExecutionComplete { runEndTime?: number; lastRunSuccess?: boolean; diff --git a/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookCellDiagnostics.test.ts b/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookCellDiagnostics.test.ts index 24f75181ddb..5ee3b23e915 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookCellDiagnostics.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookCellDiagnostics.test.ts @@ -7,7 +7,6 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; -import { waitForState } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -55,6 +54,7 @@ suite('notebookCellDiagnostics', () => { interface ITestMarkerService extends IMarkerService { markers: ResourceMap; + onMarkersUpdated: Event; } setup(function () { @@ -88,9 +88,12 @@ suite('notebookCellDiagnostics', () => { instantiationService.stub(IChatAgentService, chatAgentService); markerService = new class extends mock() { + private _onMarkersUpdated = new Emitter(); + override onMarkersUpdated = this._onMarkersUpdated.event; override markers: ResourceMap = new ResourceMap(); override changeOne(owner: string, resource: URI, markers: IMarkerData[]) { this.markers.set(resource, markers); + this._onMarkersUpdated.fire(); } }; instantiationService.stub(IMarkerService, markerService); @@ -107,16 +110,18 @@ suite('notebookCellDiagnostics', () => { disposables.add(instantiationService.createInstance(CellDiagnostics, editor)); + cell.model.internalMetadata.lastRunSuccess = false; cell.model.internalMetadata.error = { - message: 'error', + name: 'error', + message: 'something bad happened', stack: 'line 1 : print(x)', uri: cell.uri, location: { startColumn: 1, endColumn: 5, startLineNumber: 1, endLineNumber: 1 } }; testExecutionService.fireExecutionChanged(editor.textModel.uri, cell.handle); + await new Promise(resolve => Event.once(markerService.onMarkersUpdated)(resolve)); - await waitForState(cell.excecutionError, error => !!error); - assert.strictEqual(cell?.excecutionError.get()?.message, 'error'); + assert.strictEqual(cell?.executionError.get()?.message, 'something bad happened'); assert.equal(markerService.markers.get(cell.uri)?.length, 1); }, instantiationService); }); @@ -131,14 +136,18 @@ suite('notebookCellDiagnostics', () => { disposables.add(instantiationService.createInstance(CellDiagnostics, editor)); + cell.model.internalMetadata.lastRunSuccess = false; cell.model.internalMetadata.error = { - message: 'error', + name: 'error', + message: 'something bad happened', stack: 'line 1 : print(x)', uri: cell.uri, location: { startColumn: 1, endColumn: 5, startLineNumber: 1, endLineNumber: 1 } }; + cell2.model.internalMetadata.lastRunSuccess = false; cell2.model.internalMetadata.error = { - message: 'another error', + name: 'error', + message: 'another bad thing happened', stack: 'line 1 : print(y)', uri: cell.uri, location: { startColumn: 1, endColumn: 5, startLineNumber: 1, endLineNumber: 1 } @@ -146,17 +155,16 @@ suite('notebookCellDiagnostics', () => { testExecutionService.fireExecutionChanged(editor.textModel.uri, cell.handle); testExecutionService.fireExecutionChanged(editor.textModel.uri, cell2.handle); - await waitForState(cell.excecutionError, error => !!error); - await waitForState(cell2.excecutionError, error => !!error); + await new Promise(resolve => Event.once(markerService.onMarkersUpdated)(resolve)); cell.model.internalMetadata.error = undefined; // on NotebookCellExecution value will make it look like its currently running testExecutionService.fireExecutionChanged(editor.textModel.uri, cell.handle, {} as INotebookCellExecution); - await waitForState(cell.excecutionError, error => error === undefined); + await new Promise(resolve => Event.once(markerService.onMarkersUpdated)(resolve)); - assert.strictEqual(cell?.excecutionError.get(), undefined); - assert.strictEqual(cell2?.excecutionError.get()?.message, 'another error', 'cell that was not executed should still have an error'); + assert.strictEqual(cell?.executionError.get(), undefined); + assert.strictEqual(cell2?.executionError.get()?.message, 'another bad thing happened', 'cell that was not executed should still have an error'); assert.equal(markerService.markers.get(cell.uri)?.length, 0); assert.equal(markerService.markers.get(cell2.uri)?.length, 1); }, instantiationService); diff --git a/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts b/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts index 663e4a1d78d..9c476d5c609 100644 --- a/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts +++ b/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts @@ -173,7 +173,7 @@ export class DefineKeybindingWidget extends Widget { this._domNode.domNode.style.color = asCssVariable(editorWidgetForeground); this._domNode.domNode.style.boxShadow = `0 2px 8px ${asCssVariable(widgetShadow)}`; - this._keybindingInputWidget = this._register(this.instantiationService.createInstance(KeybindingsSearchWidget, this._domNode.domNode, { ariaLabel: message, history: [], inputBoxStyles: defaultInputBoxStyles })); + this._keybindingInputWidget = this._register(this.instantiationService.createInstance(KeybindingsSearchWidget, this._domNode.domNode, { ariaLabel: message, history: new Set([]), inputBoxStyles: defaultInputBoxStyles })); this._keybindingInputWidget.startRecordingKeys(); this._register(this._keybindingInputWidget.onKeybinding(keybinding => this.onKeybinding(keybinding))); this._register(this._keybindingInputWidget.onEnter(() => this.hide())); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts index 093251bea9e..ce54a54ab03 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts @@ -5,6 +5,7 @@ import { BrowserFeatures } from '../../../../base/browser/canIUse.js'; import * as DOM from '../../../../base/browser/dom.js'; +import * as domStylesheetsJs from '../../../../base/browser/domStylesheets.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { renderMarkdownAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { IMouseEvent } from '../../../../base/browser/mouseEvent.js'; @@ -2513,7 +2514,7 @@ export class SettingsTree extends WorkbenchObjectTree { } }, accessibilityProvider: new SettingsTreeAccessibilityProvider(configurationService, languageService, userDataProfilesService), - styleController: id => new DefaultStyleController(DOM.createStyleSheet(container), id), + styleController: id => new DefaultStyleController(domStylesheetsJs.createStyleSheet(container), id), filter: instantiationService.createInstance(SettingsTreeFilter, viewState), smoothScrolling: configurationService.getValue('workbench.list.smoothScrolling'), multipleSelectionSupport: false, diff --git a/src/vs/workbench/contrib/preferences/browser/tocTree.ts b/src/vs/workbench/contrib/preferences/browser/tocTree.ts index 6acbee7ca68..891d8ca7e73 100644 --- a/src/vs/workbench/contrib/preferences/browser/tocTree.ts +++ b/src/vs/workbench/contrib/preferences/browser/tocTree.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../base/browser/dom.js'; +import * as domStylesheetsJs from '../../../../base/browser/domStylesheets.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; import { DefaultStyleController, IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js'; @@ -225,7 +226,7 @@ export class TOCTree extends WorkbenchObjectTree { return e.id; } }, - styleController: id => new DefaultStyleController(DOM.createStyleSheet(container), id), + styleController: id => new DefaultStyleController(domStylesheetsJs.createStyleSheet(container), id), accessibilityProvider: instantiationService.createInstance(SettingsAccessibilityProvider), collapseByDefault: true, horizontalScrolling: false, diff --git a/src/vs/workbench/contrib/replNotebook/browser/repl.contribution.ts b/src/vs/workbench/contrib/replNotebook/browser/repl.contribution.ts index 0df311b425f..d1fc57b9d4b 100644 --- a/src/vs/workbench/contrib/replNotebook/browser/repl.contribution.ts +++ b/src/vs/workbench/contrib/replNotebook/browser/repl.contribution.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { alert } from '../../../../base/browser/ui/aria/aria.js'; import { Event } from '../../../../base/common/event.js'; import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; @@ -18,7 +17,6 @@ import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/c import { PLAINTEXT_LANGUAGE_ID } from '../../../../editor/common/languages/modesRegistry.js'; import { localize2 } from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; -import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../platform/accessibility/common/accessibility.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -45,15 +43,15 @@ import { NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT } from '../../notebook/browser/con import { INotebookEditorOptions } from '../../notebook/browser/notebookBrowser.js'; import { NotebookEditorWidget } from '../../notebook/browser/notebookEditorWidget.js'; import * as icons from '../../notebook/browser/notebookIcons.js'; +import { ReplEditorAccessibleView } from '../../notebook/browser/replEditorAccessibleView.js'; import { INotebookEditorService } from '../../notebook/browser/services/notebookEditorService.js'; -import { getAllOutputsText } from '../../notebook/browser/viewModel/cellOutputTextHelper.js'; import { CellEditType, CellKind, NotebookSetting, NotebookWorkingCopyTypeIdentifier, REPL_EDITOR_ID } from '../../notebook/common/notebookCommon.js'; -import { IS_COMPOSITE_NOTEBOOK, MOST_RECENT_REPL_EDITOR, NOTEBOOK_CELL_LIST_FOCUSED } from '../../notebook/common/notebookContextKeys.js'; +import { IS_COMPOSITE_NOTEBOOK, MOST_RECENT_REPL_EDITOR, NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_EDITOR_FOCUSED } from '../../notebook/common/notebookContextKeys.js'; import { NotebookEditorInputOptions } from '../../notebook/common/notebookEditorInput.js'; import { INotebookEditorModelResolverService } from '../../notebook/common/notebookEditorModelResolverService.js'; import { INotebookService } from '../../notebook/common/notebookService.js'; import { isReplEditorControl, ReplEditor, ReplEditorControl } from './replEditor.js'; -import { ReplEditorAccessibilityHelp } from './replEditorAccessibilityHelp.js'; +import { ReplEditorHistoryAccessibilityHelp, ReplEditorInputAccessibilityHelp } from './replEditorAccessibilityHelp.js'; import { ReplEditorInput } from './replEditorInput.js'; type SerializedNotebookEditorData = { resource: URI; preferredResource: URI; viewType: string; options?: NotebookEditorInputOptions; label?: string }; @@ -226,7 +224,8 @@ class ReplWindowWorkingCopyEditorHandler extends Disposable implements IWorkbenc registerWorkbenchContribution2(ReplWindowWorkingCopyEditorHandler.ID, ReplWindowWorkingCopyEditorHandler, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ReplDocumentContribution.ID, ReplDocumentContribution, WorkbenchPhase.BlockRestore); -AccessibleViewRegistry.register(new ReplEditorAccessibilityHelp()); +AccessibleViewRegistry.register(new ReplEditorInputAccessibilityHelp()); +AccessibleViewRegistry.register(new ReplEditorHistoryAccessibilityHelp()); registerAction2(class extends Action2 { constructor() { @@ -238,6 +237,11 @@ registerAction2(class extends Action2 { id: MenuId.CommandPalette, when: MOST_RECENT_REPL_EDITOR, }, + keybinding: [{ + primary: KeyChord(KeyMod.Alt | KeyCode.End, KeyMod.Alt | KeyCode.End), + weight: NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT, + when: ContextKeyExpr.or(IS_COMPOSITE_NOTEBOOK, NOTEBOOK_CELL_LIST_FOCUSED.negate()) + }], precondition: MOST_RECENT_REPL_EDITOR }); } @@ -284,33 +288,34 @@ registerAction2(class extends Action2 { registerAction2(class extends Action2 { constructor() { super({ - id: 'repl.readLastExecutionOutput', - title: localize2('repl.readMostRecentExecution', 'Read Most Recent Execution Output'), + id: 'repl.input.focus', + title: localize2('repl.input.focus', 'Focus Input Editor'), category: 'REPL', - keybinding: [{ - primary: KeyChord(KeyMod.Alt | KeyCode.End, KeyMod.Alt | KeyCode.End), - weight: NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT - }], menu: { id: MenuId.CommandPalette, when: MOST_RECENT_REPL_EDITOR, }, - precondition: ContextKeyExpr.and( - ContextKeyExpr.or(IS_COMPOSITE_NOTEBOOK || NOTEBOOK_CELL_LIST_FOCUSED.negate()), - MOST_RECENT_REPL_EDITOR, - CONTEXT_ACCESSIBILITY_MODE_ENABLED) + keybinding: [{ + when: ContextKeyExpr.and(IS_COMPOSITE_NOTEBOOK, NOTEBOOK_EDITOR_FOCUSED), + weight: NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT, + primary: KeyMod.CtrlCmd | KeyCode.DownArrow + }, { + when: ContextKeyExpr.and(MOST_RECENT_REPL_EDITOR), + weight: KeybindingWeight.WorkbenchContrib + 5, + primary: KeyChord(KeyMod.Alt | KeyCode.Home, KeyMod.Alt | KeyCode.Home), + }] }); } - async run(accessor: ServicesAccessor, context?: UriComponents): Promise { + async run(accessor: ServicesAccessor): Promise { const editorService = accessor.get(IEditorService); const editorControl = editorService.activeEditorPane?.getControl(); const contextKeyService = accessor.get(IContextKeyService); - let notebookEditor: NotebookEditorWidget | undefined; - if (editorControl && isReplEditorControl(editorControl)) { - notebookEditor = editorControl.notebookEditor; - } else { + if (editorControl && isReplEditorControl(editorControl) && editorControl.notebookEditor) { + editorService.activeEditorPane?.focus(); + } + else { const uriString = MOST_RECENT_REPL_EDITOR.getValue(contextKeyService); const uri = uriString ? URI.parse(uriString) : undefined; @@ -320,22 +325,7 @@ registerAction2(class extends Action2 { const replEditor = editorService.findEditors(uri)[0]; if (replEditor) { - const editor = await editorService.openEditor({ resource: uri, options: { preserveFocus: true } }, replEditor.groupId); - const editorControl = editor?.getControl(); - - if (editorControl && isReplEditorControl(editorControl)) { - notebookEditor = editorControl.notebookEditor; - } - } - } - - const viewModel = notebookEditor?.getViewModel(); - const notebook = notebookEditor?.textModel; - if (viewModel && notebook) { - const cell = viewModel.getMostRecentlyExecutedCell(); - if (cell) { - const text = getAllOutputsText(notebook, cell); - alert(text); + await editorService.openEditor({ resource: uri, options: { preserveFocus: false } }, replEditor.groupId); } } } @@ -485,6 +475,8 @@ async function executeReplInput( } } +AccessibleViewRegistry.register(new ReplEditorAccessibleView()); + KeybindingsRegistry.registerCommandAndKeybindingRule({ id: 'list.find.replInputFocus', weight: KeybindingWeight.WorkbenchContrib + 1, diff --git a/src/vs/workbench/contrib/replNotebook/browser/replEditor.ts b/src/vs/workbench/contrib/replNotebook/browser/replEditor.ts index 9e422ebc95c..70733bc9fb5 100644 --- a/src/vs/workbench/contrib/replNotebook/browser/replEditor.ts +++ b/src/vs/workbench/contrib/replNotebook/browser/replEditor.ts @@ -5,6 +5,7 @@ import './media/interactive.css'; import * as DOM from '../../../../base/browser/dom.js'; +import * as domStylesheets from '../../../../base/browser/domStylesheets.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; @@ -59,6 +60,8 @@ import { ReplInputHintContentWidget } from '../../interactive/browser/replInputH import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { localize } from '../../../../nls.js'; +import { NotebookViewModel } from '../../notebook/browser/viewModel/notebookViewModelImpl.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; const INTERACTIVE_EDITOR_VIEW_STATE_PREFERENCE_KEY = 'InteractiveEditorViewState'; @@ -129,7 +132,8 @@ export class ReplEditor extends EditorPane implements IEditorPaneWithScrolling { @IEditorGroupsService editorGroupService: IEditorGroupsService, @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService, @INotebookExecutionStateService notebookExecutionStateService: INotebookExecutionStateService, - @IExtensionService extensionService: IExtensionService + @IExtensionService extensionService: IExtensionService, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService ) { super( REPL_EDITOR_ID, @@ -210,7 +214,7 @@ export class ReplEditor extends EditorPane implements IEditorPaneWithScrolling { } private _createLayoutStyles(): void { - this._styleElement = DOM.createStyleSheet(this._rootElement); + this._styleElement = domStylesheets.createStyleSheet(this._rootElement); const styleSheets: string[] = []; const { @@ -540,13 +544,31 @@ export class ReplEditor extends EditorPane implements IEditorPaneWithScrolling { if (addedCells.length) { const viewModel = notebookWidget.viewModel; if (viewModel) { - this._notebookWidgetService.updateReplContextKey(viewModel.notebookDocument.uri.toString()); + this.handleAppend(notebookWidget, viewModel); break; } } } } + private handleAppend(notebookWidget: NotebookEditorWidget, viewModel: NotebookViewModel) { + this._notebookWidgetService.updateReplContextKey(viewModel.notebookDocument.uri.toString()); + const navigateToCell = this._configurationService.getValue('accessibility.replEditor.autoFocusReplExecution'); + if (this._accessibilityService.isScreenReaderOptimized()) { + if (navigateToCell === 'lastExecution') { + setTimeout(() => { + const lastCellIndex = viewModel.length - 1; + if (lastCellIndex >= 0) { + const cell = viewModel.viewCells[lastCellIndex]; + notebookWidget.focusNotebookCell(cell, 'container'); + } + }, 0); + } else if (navigateToCell === 'input') { + this._codeEditorWidget.focus(); + } + } + } + override setOptions(options: INotebookEditorOptions | undefined): void { this._notebookWidget.value?.setOptions(options); super.setOptions(options); diff --git a/src/vs/workbench/contrib/replNotebook/browser/replEditorAccessibilityHelp.ts b/src/vs/workbench/contrib/replNotebook/browser/replEditorAccessibilityHelp.ts index d8ca3ffc19e..b88e9f6d355 100644 --- a/src/vs/workbench/contrib/replNotebook/browser/replEditorAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/replNotebook/browser/replEditorAccessibilityHelp.ts @@ -4,54 +4,70 @@ *--------------------------------------------------------------------------------------------*/ import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IAccessibleViewImplentation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { localize } from '../../../../nls.js'; -import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { AccessibleViewProviderId, AccessibleViewType, AccessibleContentProvider } from '../../../../platform/accessibility/browser/accessibleView.js'; import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { IVisibleEditorPane } from '../../../common/editor.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; -import { IS_COMPOSITE_NOTEBOOK } from '../../notebook/common/notebookContextKeys.js'; +import { IS_COMPOSITE_NOTEBOOK, NOTEBOOK_CELL_LIST_FOCUSED } from '../../notebook/common/notebookContextKeys.js'; -export class ReplEditorAccessibilityHelp implements IAccessibleViewImplentation { +export class ReplEditorInputAccessibilityHelp implements IAccessibleViewImplentation { readonly priority = 105; - readonly name = 'REPL Editor'; - readonly when = IS_COMPOSITE_NOTEBOOK; + readonly name = 'REPL Editor Input'; + readonly when = ContextKeyExpr.and(IS_COMPOSITE_NOTEBOOK, NOTEBOOK_CELL_LIST_FOCUSED.negate()); readonly type: AccessibleViewType = AccessibleViewType.Help; getProvider(accessor: ServicesAccessor) { - const activeEditor = accessor.get(ICodeEditorService).getActiveCodeEditor() - || accessor.get(ICodeEditorService).getFocusedCodeEditor() - || accessor.get(IEditorService).activeEditorPane; - - if (!activeEditor) { - return; - } - return getAccessibilityHelpProvider(accessor, activeEditor); + return getAccessibilityHelpProvider(accessor.get(ICodeEditorService), getAccessibilityInputHelpText()); } } -function getAccessibilityHelpText(): string { +function getAccessibilityInputHelpText(): string { return [ - localize('replEditor.overview', 'You are in a REPL Editor which contains in input box to evaluate expressions and a list of previously executed expressions and their output.'), + localize('replEditor.inputOverview', 'You are in a REPL Editor Input box which will accept code to be executed in the REPL.'), localize('replEditor.execute', 'The Execute command{0} will evaluate the expression in the input box.', ''), - localize('replEditor.readLastExecution', 'The Read Last Execution Output command{0} will read the output of the last executed item.', ''), localize('replEditor.configReadExecution', 'The setting `accessibility.replEditor.readLastExecutionOutput` controls if output will be automatically read when execution completes.'), - localize('replEditor.focusHistory', 'The Focus History command{0} will move focus to the list of previously executed items.', ''), - localize('replEditor.accessibilityView', 'Run the Open Accessbility View command{0} while navigating the history for an accessible view of the item\'s output.', ''), - localize('replEditor.focusLastItemAdded', 'The Focus Last executed command{0} will move focus to the last executed item without needing to first focus on the editor.', ''), - localize('replEditor.focusReplInput', 'The Focus Input Editor command{0} will move focus to the REPL input box.', ''), - localize('replEditor.cellNavigation', 'The up and down arrows will also move focus between previously executed items.'), - localize('replEditor.focusInOutput', 'The Focus Output command{0} will set focus on the output when focused on a previously executed item.', ''), + localize('replEditor.autoFocusRepl', 'The setting `accessibility.replEditor.autoFocusReplExecution` controls if focus will automatically move to the REPL after executing code.'), + localize('replEditor.focusLastItemAdded', 'The Focus Last executed command{0} will move focus to the last executed item in the REPL history.', ''), + localize('replEditor.inputAccessibilityView', 'When you run the Open Accessbility View command{0} from this input box, the output from the last execution will be shown in the accessibility view.', ''), + localize('replEditor.focusReplInput', 'The Focus Input Editor command{0} will bring the focus back to this editor.', ''), ].join('\n'); } -function getAccessibilityHelpProvider(accessor: ServicesAccessor, editor: ICodeEditor | IVisibleEditorPane) { - const helpText = getAccessibilityHelpText(); +export class ReplEditorHistoryAccessibilityHelp implements IAccessibleViewImplentation { + readonly priority = 105; + readonly name = 'REPL Editor History'; + readonly when = ContextKeyExpr.and(IS_COMPOSITE_NOTEBOOK, NOTEBOOK_CELL_LIST_FOCUSED); + readonly type: AccessibleViewType = AccessibleViewType.Help; + getProvider(accessor: ServicesAccessor) { + return getAccessibilityHelpProvider(accessor.get(ICodeEditorService), getAccessibilityHistoryHelpText()); + } +} + +function getAccessibilityHistoryHelpText(): string { + return [ + localize('replEditor.historyOverview', 'You are in a REPL History which is a list of cells that have been executed in the REPL. Each cell has an input, an output, and the cell container.'), + localize('replEditor.focusCellEditor', 'The Edit Cell command{0} will move focus to the read-only editor for the input of the cell.', ''), + localize('replEditor.cellNavigation', 'The Quit Edit command{0} will move focus to the cell container, where the up and down arrows will also move focus between cells in the history.', ''), + localize('replEditor.accessibilityView', 'Run the Open Accessbility View command{0} while navigating the history for an accessible view of the item\'s output.', ''), + localize('replEditor.focusInOutput', 'The Focus Output command{0} will set focus on the output when focused on a previously executed item.', ''), + localize('replEditor.focusReplInputFromHistory', 'The Focus Input Editor command{0} will move focus to the REPL input box.', ''), + localize('replEditor.focusLastItemAdded', 'The Focus Last executed command{0} will move focus to the last executed item in the REPL history.', ''), + ].join('\n'); +} + +function getAccessibilityHelpProvider(editorService: ICodeEditorService, helpText: string) { + const activeEditor = editorService.getActiveCodeEditor() + || editorService.getFocusedCodeEditor(); + + if (!activeEditor) { + return; + } + return new AccessibleContentProvider( AccessibleViewProviderId.ReplEditor, { type: AccessibleViewType.Help }, () => helpText, - () => editor.focus(), + () => activeEditor.focus(), AccessibilityVerbositySettingId.ReplEditor, ); } diff --git a/src/vs/workbench/contrib/scm/browser/diff.ts b/src/vs/workbench/contrib/scm/browser/diff.ts new file mode 100644 index 00000000000..9f8a925bcc7 --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/diff.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ResourceMap } from '../../../../base/common/map.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { DirtyDiffModel } from './dirtydiffDecorator.js'; +import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; +import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { URI } from '../../../../base/common/uri.js'; + +export const IDirtyDiffModelService = createDecorator('IDirtyDiffModelService'); + +export interface IDirtyDiffModelService { + _serviceBrand: undefined; + getOrCreateModel(uri: URI): DirtyDiffModel | undefined; +} + +export class DirtyDiffModelService extends Disposable implements IDirtyDiffModelService { + _serviceBrand: undefined; + + private _models = new ResourceMap(); + + private _visibleTextEditorControls = observableFromEvent( + this.editorService.onDidVisibleEditorsChange, + () => this.editorService.visibleTextEditorControls); + + constructor( + @IEditorService private readonly editorService: IEditorService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ITextFileService private readonly textFileService: ITextFileService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService + ) { + super(); + + this._register(autorun(reader => { + const visibleTextEditorControls = this._visibleTextEditorControls.read(reader); + + // Dispose models for editors that are not visible + for (const [uri, dirtyDiffModel] of this._models) { + const textEditorControl = visibleTextEditorControls + .find(editor => isCodeEditor(editor) && + this.uriIdentityService.extUri.isEqual(editor.getModel()?.uri, uri)); + + if (textEditorControl) { + continue; + } + + dirtyDiffModel.dispose(); + this._models.delete(uri); + } + })); + } + + getOrCreateModel(uri: URI): DirtyDiffModel | undefined { + let model = this._models.get(uri); + if (!model) { + const textFileModel = this.textFileService.files.get(uri); + if (!textFileModel?.isResolved()) { + return undefined; + } + + model = this.instantiationService.createInstance(DirtyDiffModel, textFileModel); + this._models.set(uri, model); + } + + return model; + } +} + +registerSingleton(IDirtyDiffModelService, DirtyDiffModelService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts b/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts index 3c109b95e5b..8479aa0c444 100644 --- a/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts +++ b/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts @@ -43,6 +43,7 @@ import { equals, sortedDiff } from '../../../../base/common/arrays.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { ISplice } from '../../../../base/common/sequence.js'; import * as dom from '../../../../base/browser/dom.js'; +import * as domStylesheetsJs from '../../../../base/browser/domStylesheets.js'; import { EncodingMode, ITextFileEditorModel, IResolvedTextFileEditorModel, ITextFileService, isTextFileEditorModel } from '../../../services/textfile/common/textfiles.js'; import { gotoNextLocation, gotoPreviousLocation } from '../../../../platform/theme/common/iconRegistry.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -721,7 +722,7 @@ export class DirtyDiffController extends Disposable implements DirtyDiffContribu ) { super(); this.enabled = !contextKeyService.getContextKeyValue('isInDiffEditor'); - this.stylesheet = dom.createStyleSheet(undefined, undefined, this._store); + this.stylesheet = domStylesheetsJs.createStyleSheet(undefined, undefined, this._store); if (this.enabled) { this.isDirtyDiffVisible = isDirtyDiffVisible.bindTo(contextKeyService); @@ -1212,7 +1213,7 @@ export class DirtyDiffModel extends Disposable { private _model: ITextFileEditorModel; get original(): ITextModel[] { return this._originalTextModels; } - private diffDelayer = new ThrottledDelayer<{ changes: LabeledChange[]; mapChanges: Map } | null>(200); + private diffDelayer = new ThrottledDelayer(200); private _quickDiffsPromise?: Promise; private repositoryDisposables = new Set(); private readonly originalModelDisposables = this._register(new DisposableStore()); @@ -1295,14 +1296,15 @@ export class DirtyDiffModel extends Disposable { this.triggerDiff(); } - private triggerDiff(): Promise { + private triggerDiff(): void { if (!this.diffDelayer) { - return Promise.resolve(); + return; } - return this.diffDelayer - .trigger(() => this.diff()) - .then((result: { changes: LabeledChange[]; mapChanges: Map } | null) => { + this.diffDelayer + .trigger(async () => { + const result: { changes: LabeledChange[]; mapChanges: Map } | null = await this.diff(); + const originalModels = Array.from(this._originalModels.values()); if (!result || this._disposed || this._model.isDisposed() || originalModels.some(originalModel => originalModel.isDisposed())) { return; // disposed @@ -1317,7 +1319,8 @@ export class DirtyDiffModel extends Disposable { } this.setChanges(result.changes, result.mapChanges); - }, (err) => onUnexpectedError(err)); + }) + .catch(err => onUnexpectedError(err)); } private setChanges(changes: LabeledChange[], mapChanges: Map): void { @@ -1543,7 +1546,7 @@ export class DirtyDiffWorkbenchController extends Disposable implements ext.IWor @ITextFileService private readonly textFileService: ITextFileService ) { super(); - this.stylesheet = dom.createStyleSheet(undefined, undefined, this._store); + this.stylesheet = domStylesheetsJs.createStyleSheet(undefined, undefined, this._store); const onDidChangeConfiguration = Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('scm.diffDecorations')); this._register(onDidChangeConfiguration(this.onDidChangeConfiguration, this)); diff --git a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts index b78355be9e6..8b786a6d407 100644 --- a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts @@ -61,7 +61,6 @@ import { compare } from '../../../../base/common/strings.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { groupBy as groupBy2 } from '../../../../base/common/collections.js'; @@ -196,7 +195,9 @@ registerAction2(class extends ViewAction { title: localize('goToCurrentHistoryItem', "Go to Current History Item"), icon: Codicon.target, viewId: HISTORY_VIEW_PANE_ID, - precondition: ContextKeys.SCMHistoryItemCount.notEqualsTo(0), + precondition: ContextKeyExpr.and( + ContextKeys.SCMHistoryItemCount.notEqualsTo(0), + ContextKeys.SCMCurrentHistoryItemRefInFilter.isEqualTo(true)), f1: false, menu: { id: MenuId.SCMHistoryTitle, @@ -1205,12 +1206,13 @@ export class SCMHistoryViewPane extends ViewPane { private readonly _updateChildrenThrottler = new Throttler(); private readonly _scmProviderCtx: IContextKey; + private readonly _scmCurrentHistoryItemRefHasRemote: IContextKey; + private readonly _scmCurrentHistoryItemRefInFilter: IContextKey; constructor( options: IViewPaneOptions, @ICommandService private readonly _commandService: ICommandService, @IInstantiationService private readonly _instantiationService: IInstantiationService, - @INotificationService private readonly _notificationService: INotificationService, @IProgressService private readonly _progressService: IProgressService, @IConfigurationService configurationService: IConfigurationService, @IContextMenuService contextMenuService: IContextMenuService, @@ -1230,6 +1232,8 @@ export class SCMHistoryViewPane extends ViewPane { }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); this._scmProviderCtx = ContextKeys.SCMProvider.bindTo(this.scopedContextKeyService); + this._scmCurrentHistoryItemRefHasRemote = ContextKeys.SCMCurrentHistoryItemRefHasRemote.bindTo(this.scopedContextKeyService); + this._scmCurrentHistoryItemRefInFilter = ContextKeys.SCMCurrentHistoryItemRefInFilter.bindTo(this.scopedContextKeyService); this._actionRunner = this.instantiationService.createInstance(SCMHistoryViewPaneActionRunner); this._register(this._actionRunner); @@ -1311,15 +1315,15 @@ export class SCMHistoryViewPane extends ViewPane { return; } - // Update context - this._scmProviderCtx.set(repository.provider.contextValue); - // HistoryItemId changed (checkout) const historyItemRefId = derived(reader => { return historyProvider.historyItemRef.read(reader)?.id; }); - store.add(runOnChange(historyItemRefId, () => { + store.add(runOnChange(historyItemRefId, historyItemRefIdValue => { this.refresh(); + + // Update context key (needs to be done after the refresh call) + this._scmCurrentHistoryItemRefInFilter.set(this._isCurrentHistoryItemInFilter(historyItemRefIdValue)); })); // HistoryItemRefs changed @@ -1344,8 +1348,20 @@ export class SCMHistoryViewPane extends ViewPane { // HistoryItemRefs filter changed store.add(runOnChange(this._treeViewModel.onDidChangeHistoryItemsFilter, () => { this.refresh(); + + // Update context key (needs to be done after the refresh call) + this._scmCurrentHistoryItemRefInFilter.set(this._isCurrentHistoryItemInFilter(historyItemRefId.get())); })); + // HistoryItemRemoteRef changed + store.add(autorun(reader => { + this._scmCurrentHistoryItemRefHasRemote.set(!!historyProvider.historyItemRemoteRef.read(reader)); + })); + + // Update context + this._scmProviderCtx.set(repository.provider.contextValue); + this._scmCurrentHistoryItemRefInFilter.set(this._isCurrentHistoryItemInFilter(historyItemRefId.get())); + // We skip refreshing the graph on the first execution of the autorun // since the graph for the first repository is rendered when the tree // input is set. @@ -1438,16 +1454,11 @@ export class SCMHistoryViewPane extends ViewPane { const repository = this._treeViewModel.repository.get(); const historyProvider = repository?.provider.historyProvider.get(); const historyItemRef = historyProvider?.historyItemRef.get(); - const historyItemFilter = this._treeViewModel.getHistoryItemsFilter(); - - if (!repository || !historyItemRef?.revision || !historyItemFilter) { + if (!repository || !historyItemRef?.id || !historyItemRef?.revision) { return; } - // Filter set to `all`, `auto` or it contains the current history item - if (Array.isArray(historyItemFilter) && - !historyItemFilter.find(ref => ref.id === historyItemRef.id)) { - this._notificationService.info(localize('scmGraphViewRevealCurrentHistoryItem', "The current history item is not present in the source control graph. Please use the history item references picker to expand the set of history items in the graph.")); + if (this._isCurrentHistoryItemInFilter(historyItemRef.id)) { return; } @@ -1513,6 +1524,19 @@ export class SCMHistoryViewPane extends ViewPane { this._tree.onContextMenu(this._onContextMenu, this, this._store); } + private _isCurrentHistoryItemInFilter(historyItemRefId: string | undefined): boolean { + if (!historyItemRefId) { + return false; + } + + const historyItemFilter = this._treeViewModel.getHistoryItemsFilter(); + if (historyItemFilter === 'all' || historyItemFilter === 'auto') { + return true; + } + + return Array.isArray(historyItemFilter) && !!historyItemFilter.find(ref => ref.id === historyItemRefId); + } + private async _onDidOpen(e: IOpenEvent): Promise { if (!e.element) { return; diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 74b108d5488..0f397632c2e 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -964,6 +964,8 @@ export const ContextKeys = { SCMProviderRootUri: new RawContextKey('scmProviderRootUri', undefined), SCMProviderHasRootUri: new RawContextKey('scmProviderHasRootUri', undefined), SCMHistoryItemCount: new RawContextKey('scmHistoryItemCount', 0), + SCMCurrentHistoryItemRefHasRemote: new RawContextKey('scmCurrentHistoryItemRefHasRemote', false), + SCMCurrentHistoryItemRefInFilter: new RawContextKey('scmCurrentHistoryItemRefInFilter', false), RepositoryCount: new RawContextKey('scmRepositoryCount', 0), RepositoryVisibilityCount: new RawContextKey('scmRepositoryVisibleCount', 0), RepositoryVisibility(repository: ISCMRepository) { diff --git a/src/vs/workbench/contrib/search/browser/patternInputWidget.ts b/src/vs/workbench/contrib/search/browser/patternInputWidget.ts index 04e908abef1..f3402feca05 100644 --- a/src/vs/workbench/contrib/search/browser/patternInputWidget.ts +++ b/src/vs/workbench/contrib/search/browser/patternInputWidget.ts @@ -140,6 +140,7 @@ export class PatternInputWidget extends Widget { private render(options: IOptions): void { this.domNode = document.createElement('div'); this.domNode.classList.add('monaco-findInput'); + const history = options.history || []; this.inputBox = new ContextScopedHistoryInputBox(this.domNode, this.contextViewProvider, { placeholder: options.placeholder, @@ -149,7 +150,7 @@ export class PatternInputWidget extends Widget { validationOptions: { validation: undefined }, - history: options.history || [], + history: new Set(history), showHistoryHint: () => showHistoryKeybindingHint(this.keybindingService), inputBoxStyles: options.inputBoxStyles }, this.contextKeyService); diff --git a/src/vs/workbench/contrib/search/browser/searchWidget.ts b/src/vs/workbench/contrib/search/browser/searchWidget.ts index 7ff1a4b23ac..3b1bab8cf71 100644 --- a/src/vs/workbench/contrib/search/browser/searchWidget.ts +++ b/src/vs/workbench/contrib/search/browser/searchWidget.ts @@ -394,6 +394,7 @@ export class SearchWidget extends Widget { } private renderSearchInput(parent: HTMLElement, options: ISearchWidgetOptions): void { + const history = options.searchHistory || []; const inputOptions: IFindInputOptions = { label: nls.localize('label.Search', 'Search: Type Search Term and press Enter to search'), validation: (value: string) => this.validateSearchInput(value), @@ -401,7 +402,7 @@ export class SearchWidget extends Widget { appendCaseSensitiveLabel: appendKeyBindingLabel('', this.keybindingService.lookupKeybinding(Constants.SearchCommandIds.ToggleCaseSensitiveCommandId)), appendWholeWordsLabel: appendKeyBindingLabel('', this.keybindingService.lookupKeybinding(Constants.SearchCommandIds.ToggleWholeWordCommandId)), appendRegexLabel: appendKeyBindingLabel('', this.keybindingService.lookupKeybinding(Constants.SearchCommandIds.ToggleRegexCommandId)), - history: options.searchHistory, + history: new Set(history), showHistoryHint: () => showHistoryKeybindingHint(this.keybindingService), flexibleHeight: true, flexibleMaxHeight: SearchWidget.INPUT_MAX_HEIGHT, diff --git a/src/vs/workbench/contrib/terminal/browser/terminalIcon.ts b/src/vs/workbench/contrib/terminal/browser/terminalIcon.ts index fedb7cd9821..fb4d5755431 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalIcon.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalIcon.ts @@ -14,7 +14,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { ITerminalInstance } from './terminal.js'; import { ITerminalProfileResolverService } from '../common/terminal.js'; import { ansiColorMap } from '../common/terminalColorRegistry.js'; -import { createStyleSheet } from '../../../../base/browser/dom.js'; +import { createStyleSheet } from '../../../../base/browser/domStylesheets.js'; import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index 028b6d9ddd2..70d2aa007ec 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as dom from '../../../../base/browser/dom.js'; -import * as cssJs from '../../../../base/browser/cssValue.js'; +import * as domStylesheets from '../../../../base/browser/domStylesheets.js'; +import * as cssValue from '../../../../base/browser/cssValue.js'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { debounce, memoize } from '../../../../base/common/decorators.js'; import { DynamicListEventMultiplexer, Emitter, Event, IDynamicListEventMultiplexer } from '../../../../base/common/event.js'; @@ -1213,7 +1213,7 @@ class TerminalEditorStyle extends Themable { ) { super(_themeService); this._registerListeners(); - this._styleElement = dom.createStyleSheet(container); + this._styleElement = domStylesheets.createStyleSheet(container); this._register(toDisposable(() => this._styleElement.remove())); this.updateStyles(); } @@ -1258,8 +1258,8 @@ class TerminalEditorStyle extends Themable { const iconClasses = getUriClasses(instance, colorTheme.type); if (uri instanceof URI && iconClasses && iconClasses.length > 1) { css += ( - `.monaco-workbench .terminal-tab.${iconClasses[0]}::before` + - `{content: ''; background-image: ${cssJs.asCSSUrl(uri)};}` + cssValue.inline`.monaco-workbench .terminal-tab.${cssValue.className(iconClasses[0])}::before + {content: ''; background-image: ${cssValue.asCSSUrl(uri)};}` ); } if (ThemeIcon.isThemeIcon(icon)) { @@ -1268,10 +1268,8 @@ class TerminalEditorStyle extends Themable { if (iconContribution) { const def = productIconTheme.getIcon(iconContribution); if (def) { - css += ( - `.monaco-workbench .terminal-tab.codicon-${icon.id}::before` + - `{content: '${def.fontCharacter}' !important; font-family: ${cssJs.asCSSPropertyValue(def.font?.id ?? 'codicon')} !important;}` - ); + css += cssValue.inline`.monaco-workbench .terminal-tab.codicon-${cssValue.className(icon.id)}::before + {content: ${cssValue.stringValue(def.fontCharacter)} !important; font-family: ${cssValue.stringValue(def.font?.id ?? 'codicon')} !important;}`; } } } @@ -1280,7 +1278,7 @@ class TerminalEditorStyle extends Themable { // Add colors const iconForegroundColor = colorTheme.getColor(iconForeground); if (iconForegroundColor) { - css += `.monaco-workbench .show-file-icons .file-icon.terminal-tab::before { color: ${iconForegroundColor}; }`; + css += cssValue.inline`.monaco-workbench .show-file-icons .file-icon.terminal-tab::before { color: ${iconForegroundColor}; }`; } css += getColorStyleContent(colorTheme, true); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalView.ts b/src/vs/workbench/contrib/terminal/browser/terminalView.ts index 903d5611bd8..b71064b87f0 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalView.ts @@ -5,6 +5,7 @@ import * as nls from '../../../../nls.js'; import * as dom from '../../../../base/browser/dom.js'; +import * as domStylesheetsJs from '../../../../base/browser/domStylesheets.js'; import * as cssJs from '../../../../base/browser/cssValue.js'; import { Action, IAction } from '../../../../base/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -187,7 +188,7 @@ export class TerminalViewPane extends ViewPane { } this._parentDomElement = container; this._parentDomElement.classList.add('integrated-terminal'); - dom.createStyleSheet(this._parentDomElement); + domStylesheetsJs.createStyleSheet(this._parentDomElement); this._instantiationService.createInstance(TerminalThemeIconStyle, this._parentDomElement); if (!this.shouldShowWelcome()) { @@ -584,7 +585,7 @@ class TerminalThemeIconStyle extends Themable { ) { super(_themeService); this._registerListeners(); - this._styleElement = dom.createStyleSheet(container); + this._styleElement = domStylesheetsJs.createStyleSheet(container); this._register(toDisposable(() => this._styleElement.remove())); this.updateStyles(); } diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts index 4785e6c5b7c..c7f9cb61c25 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts @@ -865,7 +865,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo private registerManageSyncAction(): void { const that = this; - const when = ContextKeyExpr.and(CONTEXT_SYNC_ENABLEMENT, CONTEXT_ACCOUNT_STATE.isEqualTo(AccountStatus.Available), CONTEXT_SYNC_STATE.notEqualsTo(SyncStatus.Uninitialized)); + const when = ContextKeyExpr.and(CONTEXT_SYNC_ENABLEMENT, CONTEXT_ACCOUNT_STATE.notEqualsTo(AccountStatus.Unavailable), CONTEXT_SYNC_STATE.notEqualsTo(SyncStatus.Uninitialized)); this._register(registerAction2(class SyncStatusAction extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/contrib/webviewPanel/browser/webviewIconManager.ts b/src/vs/workbench/contrib/webviewPanel/browser/webviewIconManager.ts index 89e08638545..1452b2f37d0 100644 --- a/src/vs/workbench/contrib/webviewPanel/browser/webviewIconManager.ts +++ b/src/vs/workbench/contrib/webviewPanel/browser/webviewIconManager.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as dom from '../../../../base/browser/dom.js'; -import * as cssJs from '../../../../base/browser/cssValue.js'; +import * as cssValue from '../../../../base/browser/cssValue.js'; +import * as domStylesheets from '../../../../base/browser/domStylesheets.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -39,7 +39,7 @@ export class WebviewIconManager extends Disposable { private get styleElement(): HTMLStyleElement { if (!this._styleElement) { - this._styleElement = dom.createStyleSheet(undefined, undefined, this._store); + this._styleElement = domStylesheets.createStyleSheet(undefined, undefined, this._store); this._styleElement.className = 'webview-icons'; } return this._styleElement; @@ -67,8 +67,8 @@ export class WebviewIconManager extends Disposable { const webviewSelector = `.show-file-icons .webview-${key}-name-file-icon::before`; try { cssRules.push( - `.monaco-workbench.vs ${webviewSelector}, .monaco-workbench.hc-light ${webviewSelector} { content: ""; background-image: ${cssJs.asCSSUrl(value.light)}; }`, - `.monaco-workbench.vs-dark ${webviewSelector}, .monaco-workbench.hc-black ${webviewSelector} { content: ""; background-image: ${cssJs.asCSSUrl(value.dark)}; }` + `.monaco-workbench.vs ${webviewSelector}, .monaco-workbench.hc-light ${webviewSelector} { content: ""; background-image: ${cssValue.asCSSUrl(value.light)}; }`, + `.monaco-workbench.vs-dark ${webviewSelector}, .monaco-workbench.hc-black ${webviewSelector} { content: ""; background-image: ${cssValue.asCSSUrl(value.dark)}; }` ); } catch { // noop diff --git a/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts b/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts index c7fe91bfe1e..ee504955a5a 100644 --- a/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts +++ b/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { getZoomLevel } from '../../../../base/browser/browser.js'; -import { Dimension, EventHelper, EventType, ModifierKeyEmitter, addDisposableListener, cloneGlobalStylesheets, copyAttributes, createLinkElement, createMetaElement, getActiveWindow, getClientArea, getWindowId, isGlobalStylesheet, isHTMLElement, position, registerWindow, sharedMutationObserver, trackAttributes } from '../../../../base/browser/dom.js'; +import { Dimension, EventHelper, EventType, ModifierKeyEmitter, addDisposableListener, copyAttributes, createLinkElement, createMetaElement, getActiveWindow, getClientArea, getWindowId, isHTMLElement, position, registerWindow, sharedMutationObserver, trackAttributes } from '../../../../base/browser/dom.js'; +import { cloneGlobalStylesheets, isGlobalStylesheet } from '../../../../base/browser/domStylesheets.js'; import { CodeWindow, ensureCodeWindow, mainWindow } from '../../../../base/browser/window.js'; import { coalesce } from '../../../../base/common/arrays.js'; import { Barrier } from '../../../../base/common/async.js'; diff --git a/src/vs/workbench/services/configuration/browser/configuration.ts b/src/vs/workbench/services/configuration/browser/configuration.ts index d18242bde57..217084c9e9b 100644 --- a/src/vs/workbench/services/configuration/browser/configuration.ts +++ b/src/vs/workbench/services/configuration/browser/configuration.ts @@ -133,7 +133,7 @@ export class ApplicationConfiguration extends UserSettings { uriIdentityService: IUriIdentityService, logService: ILogService, ) { - super(userDataProfilesService.defaultProfile.settingsResource, { scopes: [ConfigurationScope.APPLICATION] }, uriIdentityService.extUri, fileService, logService); + super(userDataProfilesService.defaultProfile.settingsResource, { scopes: [ConfigurationScope.APPLICATION], skipUnregistered: true }, uriIdentityService.extUri, fileService, logService); this._register(this.onDidChange(() => this.reloadConfigurationScheduler.schedule())); this.reloadConfigurationScheduler = this._register(new RunOnceScheduler(() => this.loadConfiguration().then(configurationModel => this._onDidChangeConfiguration.fire(configurationModel)), 50)); } diff --git a/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts b/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts index ebcc88dd25d..833dec287e0 100644 --- a/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts +++ b/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts @@ -1737,6 +1737,12 @@ suite('WorkspaceConfigurationService - Profiles', () => { assert.strictEqual(testObject.getValue('configurationService.profiles.applicationSetting3'), 'defaultProfile'); })); + test('non registering setting should not be read from default profile', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await fileService.writeFile(instantiationService.get(IUserDataProfilesService).defaultProfile.settingsResource, VSBuffer.fromString('{ "configurationService.profiles.nonregistered": "defaultProfile" }')); + await testObject.reloadConfiguration(); + assert.strictEqual(testObject.getValue('configurationService.profiles.nonregistered'), undefined); + })); + test('initialize with custom all profiles settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { await testObject.updateValue(APPLY_ALL_PROFILES_SETTING, ['configurationService.profiles.testSetting2'], ConfigurationTarget.USER_LOCAL); diff --git a/src/vs/workbench/services/decorations/browser/decorationsService.ts b/src/vs/workbench/services/decorations/browser/decorationsService.ts index 49213f302d3..16344696c82 100644 --- a/src/vs/workbench/services/decorations/browser/decorationsService.ts +++ b/src/vs/workbench/services/decorations/browser/decorationsService.ts @@ -10,8 +10,8 @@ import { TernarySearchTree } from '../../../../base/common/ternarySearchTree.js' import { IDisposable, toDisposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { isThenable } from '../../../../base/common/async.js'; import { LinkedList } from '../../../../base/common/linkedList.js'; -import { createStyleSheet, createCSSRule, removeCSSRulesContainingSelector } from '../../../../base/browser/dom.js'; -import { asCSSPropertyValue } from '../../../../base/browser/cssValue.js'; +import { createStyleSheet, createCSSRule, removeCSSRulesContainingSelector } from '../../../../base/browser/domStylesheets.js'; +import * as cssValue from '../../../../base/browser/cssValue.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { isFalsyOrWhitespace } from '../../../../base/common/strings.js'; @@ -139,7 +139,7 @@ class DecorationRule { `.${this.iconBadgeClassName}::after`, `content: '${definition.fontCharacter}'; color: ${icon.color ? getColor(icon.color.id) : getColor(color)}; - font-family: ${asCSSPropertyValue(definition.font?.id ?? 'codicon')}; + font-family: ${cssValue.stringValue(definition.font?.id ?? 'codicon')}; font-size: 16px; margin-right: 14px; font-weight: normal; diff --git a/src/vs/workbench/services/extensions/browser/extensionService.ts b/src/vs/workbench/services/extensions/browser/extensionService.ts index 818514fdd22..69aad42da57 100644 --- a/src/vs/workbench/services/extensions/browser/extensionService.ts +++ b/src/vs/workbench/services/extensions/browser/extensionService.ts @@ -26,7 +26,7 @@ import { IBrowserWorkbenchEnvironmentService } from '../../environment/browser/e import { IWebExtensionsScannerService, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../extensionManagement/common/extensionManagement.js'; import { IWebWorkerExtensionHostDataProvider, IWebWorkerExtensionHostInitData, WebWorkerExtensionHost } from './webWorkerExtensionHost.js'; import { FetchFileSystemProvider } from './webWorkerFileSystemProvider.js'; -import { AbstractExtensionService, IExtensionHostFactory, ResolvedExtensions, checkEnabledAndProposedAPI } from '../common/abstractExtensionService.js'; +import { AbstractExtensionService, IExtensionHostFactory, LocalExtensions, RemoteExtensions, ResolvedExtensions, ResolverExtensions, checkEnabledAndProposedAPI, isResolverExtension } from '../common/abstractExtensionService.js'; import { ExtensionDescriptionRegistrySnapshot } from '../common/extensionDescriptionRegistry.js'; import { ExtensionHostKind, ExtensionRunningPreference, IExtensionHostKindPicker, extensionHostKindToString, extensionRunningPreferenceToString } from '../common/extensionHostKind.js'; import { IExtensionManifestPropertiesService } from '../common/extensionManifestPropertiesService.js'; @@ -41,6 +41,7 @@ import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; import { IRemoteExplorerService } from '../../remote/common/remoteExplorerService.js'; import { IUserDataInitializationService } from '../../userData/browser/userDataInit.js'; import { IUserDataProfileService } from '../../userDataProfile/common/userDataProfile.js'; +import { AsyncIterableEmitter, AsyncIterableObject } from '../../../../base/common/async.js'; export class ExtensionService extends AbstractExtensionService implements IExtensionService { @@ -80,6 +81,7 @@ export class ExtensionService extends AbstractExtensionService implements IExten logService ); super( + { hasLocalProcess: false, allowRemoteExtensionsInLocalWebWorker: true }, extensionsProposedApi, extensionHostFactory, new BrowserExtensionHostKindPicker(logService), @@ -117,32 +119,45 @@ export class ExtensionService extends AbstractExtensionService implements IExten this._register(this._fileService.registerProvider(Schemas.https, provider)); } + private _scanWebExtensionsPromise: Promise | undefined; private async _scanWebExtensions(): Promise { - const system: IExtensionDescription[] = [], user: IExtensionDescription[] = [], development: IExtensionDescription[] = []; - try { - await Promise.all([ - this._webExtensionsScannerService.scanSystemExtensions().then(extensions => system.push(...extensions.map(e => toExtensionDescription(e)))), - this._webExtensionsScannerService.scanUserExtensions(this._userDataProfileService.currentProfile.extensionsResource, { skipInvalidExtensions: true }).then(extensions => user.push(...extensions.map(e => toExtensionDescription(e)))), - this._webExtensionsScannerService.scanExtensionsUnderDevelopment().then(extensions => development.push(...extensions.map(e => toExtensionDescription(e, true)))) - ]); - } catch (error) { - this._logService.error(error); + if (!this._scanWebExtensionsPromise) { + this._scanWebExtensionsPromise = (async () => { + const system: IExtensionDescription[] = [], user: IExtensionDescription[] = [], development: IExtensionDescription[] = []; + try { + await Promise.all([ + this._webExtensionsScannerService.scanSystemExtensions().then(extensions => system.push(...extensions.map(e => toExtensionDescription(e)))), + this._webExtensionsScannerService.scanUserExtensions(this._userDataProfileService.currentProfile.extensionsResource, { skipInvalidExtensions: true }).then(extensions => user.push(...extensions.map(e => toExtensionDescription(e)))), + this._webExtensionsScannerService.scanExtensionsUnderDevelopment().then(extensions => development.push(...extensions.map(e => toExtensionDescription(e, true)))) + ]); + } catch (error) { + this._logService.error(error); + } + return dedupExtensions(system, user, [], development, this._logService); + })(); } - return dedupExtensions(system, user, [], development, this._logService); + return this._scanWebExtensionsPromise; } - protected async _resolveExtensionsDefault() { + private async _resolveExtensionsDefault(emitter: AsyncIterableEmitter) { const [localExtensions, remoteExtensions] = await Promise.all([ this._scanWebExtensions(), this._remoteExtensionsScannerService.scanExtensions() ]); - return new ResolvedExtensions(localExtensions, remoteExtensions, /*hasLocalProcess*/false, /*allowRemoteExtensionsInLocalWebWorker*/true); + if (remoteExtensions.length) { + emitter.emitOne(new RemoteExtensions(remoteExtensions)); + } + emitter.emitOne(new LocalExtensions(localExtensions)); } - protected async _resolveExtensions(): Promise { + protected _resolveExtensions(): AsyncIterable { + return new AsyncIterableObject(emitter => this._doResolveExtensions(emitter)); + } + + private async _doResolveExtensions(emitter: AsyncIterableEmitter): Promise { if (!this._browserEnvironmentService.expectsResolverExtension) { - return this._resolveExtensionsDefault(); + return this._resolveExtensionsDefault(emitter); } const remoteAuthority = this._environmentService.remoteAuthority!; @@ -152,6 +167,11 @@ export class ExtensionService extends AbstractExtensionService implements IExten // override the trust state through the resolver result. await this._workspaceTrustManagementService.workspaceResolved; + const localExtensions = await this._scanWebExtensions(); + const resolverExtensions = localExtensions.filter(extension => isResolverExtension(extension)); + if (resolverExtensions.length) { + emitter.emitOne(new ResolverExtensions(resolverExtensions)); + } let resolverResult: ResolverResult; try { @@ -163,7 +183,7 @@ export class ExtensionService extends AbstractExtensionService implements IExten this._remoteAuthorityResolverService._setResolvedAuthorityError(remoteAuthority, err); // Proceed with the local extension host - return this._resolveExtensionsDefault(); + return this._resolveExtensionsDefault(emitter); } // set the resolved authority @@ -181,7 +201,7 @@ export class ExtensionService extends AbstractExtensionService implements IExten connection.onReconnecting(() => this._resolveAuthorityAgain()); } - return this._resolveExtensionsDefault(); + return this._resolveExtensionsDefault(emitter); } protected async _onExtensionHostExit(code: number): Promise { diff --git a/src/vs/workbench/services/extensions/common/abstractExtensionService.ts b/src/vs/workbench/services/extensions/common/abstractExtensionService.ts index b14b72523de..68b71d7ce1b 100644 --- a/src/vs/workbench/services/extensions/common/abstractExtensionService.ts +++ b/src/vs/workbench/services/extensions/common/abstractExtensionService.ts @@ -61,6 +61,9 @@ export abstract class AbstractExtensionService extends Disposable implements IEx public _serviceBrand: undefined; + private readonly _hasLocalProcess: boolean; + private readonly _allowRemoteExtensionsInLocalWebWorker: boolean; + private readonly _onDidRegisterExtensions = this._register(new Emitter()); public readonly onDidRegisterExtensions = this._onDidRegisterExtensions.event; @@ -95,6 +98,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx private _resolveAuthorityAttempt: number = 0; constructor( + options: { hasLocalProcess: boolean; allowRemoteExtensionsInLocalWebWorker: boolean }, private readonly _extensionsProposedApi: ExtensionsProposedApi, private readonly _extensionHostFactory: IExtensionHostFactory, private readonly _extensionHostKindPicker: IExtensionHostKindPicker, @@ -118,6 +122,9 @@ export abstract class AbstractExtensionService extends Disposable implements IEx ) { super(); + this._hasLocalProcess = options.hasLocalProcess; + this._allowRemoteExtensionsInLocalWebWorker = options.allowRemoteExtensionsInLocalWebWorker; + // help the file service to activate providers by activating extensions by file system event this._register(this._fileService.onWillActivateFileSystemProvider(e => { if (e.scheme !== Schemas.vscodeRemote) { @@ -318,7 +325,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx this._extensionsProposedApi.updateEnabledApiProposals(toAdd); // Update extension points - this._doHandleExtensionPoints(([]).concat(toAdd).concat(toRemove)); + this._doHandleExtensionPoints(([]).concat(toAdd).concat(toRemove), false); // Update the extension host await this._updateExtensionsOnExtHosts(result.versionId, toAdd, toRemove.map(e => e.identifier)); @@ -453,10 +460,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx const lock = await this._registry.acquireLock('_initialize'); try { - const resolvedExtensions = await this._resolveExtensions(); - - this._processExtensions(lock, resolvedExtensions); - + await this._resolveAndProcessExtensions(lock); // Start extension hosts which are not automatically started const snapshot = this._registry.getSnapshot(); for (const extHostManager of this._extensionHostManagers) { @@ -474,10 +478,24 @@ export abstract class AbstractExtensionService extends Disposable implements IEx await this._handleExtensionTests(); } - private _processExtensions(lock: ExtensionDescriptionRegistryLock, resolvedExtensions: ResolvedExtensions): void { - const { allowRemoteExtensionsInLocalWebWorker, hasLocalProcess } = resolvedExtensions; - const localExtensions = checkEnabledAndProposedAPI(this._logService, this._extensionEnablementService, this._extensionsProposedApi, resolvedExtensions.local, false); - let remoteExtensions = checkEnabledAndProposedAPI(this._logService, this._extensionEnablementService, this._extensionsProposedApi, resolvedExtensions.remote, false); + private async _resolveAndProcessExtensions(lock: ExtensionDescriptionRegistryLock,): Promise { + let resolverExtensions: IExtensionDescription[] = []; + let localExtensions: IExtensionDescription[] = []; + let remoteExtensions: IExtensionDescription[] = []; + + for await (const extensions of this._resolveExtensions()) { + if (extensions instanceof ResolverExtensions) { + resolverExtensions = checkEnabledAndProposedAPI(this._logService, this._extensionEnablementService, this._extensionsProposedApi, extensions.extensions, false); + this._registry.deltaExtensions(lock, resolverExtensions, []); + this._doHandleExtensionPoints(resolverExtensions, true); + } + if (extensions instanceof LocalExtensions) { + localExtensions = checkEnabledAndProposedAPI(this._logService, this._extensionEnablementService, this._extensionsProposedApi, extensions.extensions, false); + } + if (extensions instanceof RemoteExtensions) { + remoteExtensions = checkEnabledAndProposedAPI(this._logService, this._extensionEnablementService, this._extensionsProposedApi, extensions.extensions, false); + } + } // `initializeRunningLocation` will look at the complete picture (e.g. an extension installed on both sides), // takes care of duplicates and picks a running location for each extension @@ -486,8 +504,8 @@ export abstract class AbstractExtensionService extends Disposable implements IEx this._startExtensionHostsIfNecessary(true, []); // Some remote extensions could run locally in the web worker, so store them - const remoteExtensionsThatNeedToRunLocally = (allowRemoteExtensionsInLocalWebWorker ? this._runningLocations.filterByExtensionHostKind(remoteExtensions, ExtensionHostKind.LocalWebWorker) : []); - const localProcessExtensions = (hasLocalProcess ? this._runningLocations.filterByExtensionHostKind(localExtensions, ExtensionHostKind.LocalProcess) : []); + const remoteExtensionsThatNeedToRunLocally = (this._allowRemoteExtensionsInLocalWebWorker ? this._runningLocations.filterByExtensionHostKind(remoteExtensions, ExtensionHostKind.LocalWebWorker) : []); + const localProcessExtensions = (this._hasLocalProcess ? this._runningLocations.filterByExtensionHostKind(localExtensions, ExtensionHostKind.LocalProcess) : []); const localWebWorkerExtensions = this._runningLocations.filterByExtensionHostKind(localExtensions, ExtensionHostKind.LocalWebWorker); remoteExtensions = this._runningLocations.filterByExtensionHostKind(remoteExtensions, ExtensionHostKind.Remote); @@ -499,8 +517,22 @@ export abstract class AbstractExtensionService extends Disposable implements IEx } const allExtensions = remoteExtensions.concat(localProcessExtensions).concat(localWebWorkerExtensions); + let toAdd = allExtensions; - const result = this._registry.deltaExtensions(lock, allExtensions, []); + if (resolverExtensions.length) { + // Add extensions that are not registered as resolvers but are in the final resolved set + toAdd = allExtensions.filter(extension => !resolverExtensions.some(e => ExtensionIdentifier.equals(e.identifier, extension.identifier) && e.extensionLocation.toString() === extension.extensionLocation.toString())); + // Remove extensions that are registered as resolvers but are not in the final resolved set + if (allExtensions.length < toAdd.length + resolverExtensions.length) { + const toRemove = resolverExtensions.filter(registered => !allExtensions.some(e => ExtensionIdentifier.equals(e.identifier, registered.identifier) && e.extensionLocation.toString() === registered.extensionLocation.toString())); + if (toRemove.length) { + this._registry.deltaExtensions(lock, [], toRemove.map(e => e.identifier)); + this._doHandleExtensionPoints(toRemove, true); + } + } + } + + const result = this._registry.deltaExtensions(lock, toAdd, []); if (result.removedDueToLooping.length > 0) { this._notificationService.notify({ severity: Severity.Error, @@ -508,7 +540,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx }); } - this._doHandleExtensionPoints(this._registry.getAllExtensionDescriptions()); + this._doHandleExtensionPoints(this._registry.getAllExtensionDescriptions(), false); } private async _handleExtensionTests(): Promise { @@ -1031,7 +1063,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx } } - private _doHandleExtensionPoints(affectedExtensions: IExtensionDescription[]): void { + private _doHandleExtensionPoints(affectedExtensions: IExtensionDescription[], onlyResolverExtensionPoints: boolean): void { const affectedExtensionPoints: { [extPointName: string]: boolean } = Object.create(null); for (const extensionDescription of affectedExtensions) { if (extensionDescription.contributes) { @@ -1046,15 +1078,15 @@ export abstract class AbstractExtensionService extends Disposable implements IEx const messageHandler = (msg: IMessage) => this._handleExtensionPointMessage(msg); const availableExtensions = this._registry.getAllExtensionDescriptions(); const extensionPoints = ExtensionsRegistry.getExtensionPoints(); - perf.mark('code/willHandleExtensionPoints'); + perf.mark(onlyResolverExtensionPoints ? 'code/willHandleResolverExtensionPoints' : 'code/willHandleExtensionPoints'); for (const extensionPoint of extensionPoints) { - if (affectedExtensionPoints[extensionPoint.name]) { + if (affectedExtensionPoints[extensionPoint.name] && (!onlyResolverExtensionPoints || extensionPoint.canHandleResolver)) { perf.mark(`code/willHandleExtensionPoint/${extensionPoint.name}`); AbstractExtensionService._handleExtensionPoint(extensionPoint, availableExtensions, messageHandler); perf.mark(`code/didHandleExtensionPoint/${extensionPoint.name}`); } } - perf.mark('code/didHandleExtensionPoints'); + perf.mark(onlyResolverExtensionPoints ? 'code/didHandleResolverExtensionPoints' : 'code/didHandleExtensionPoints'); } private _getOrCreateExtensionStatus(extensionId: ExtensionIdentifier): ExtensionStatus { @@ -1192,7 +1224,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx //#endregion - protected abstract _resolveExtensions(): Promise; + protected abstract _resolveExtensions(): AsyncIterable; protected abstract _onExtensionHostExit(code: number): Promise; protected abstract _resolveAuthority(remoteAuthority: string): Promise; } @@ -1280,15 +1312,26 @@ class ExtensionHostManagerData { } } -export class ResolvedExtensions { +export class ResolverExtensions { constructor( - public readonly local: IExtensionDescription[], - public readonly remote: IExtensionDescription[], - public readonly hasLocalProcess: boolean, - public readonly allowRemoteExtensionsInLocalWebWorker: boolean + public readonly extensions: IExtensionDescription[], ) { } } +export class LocalExtensions { + constructor( + public readonly extensions: IExtensionDescription[], + ) { } +} + +export class RemoteExtensions { + constructor( + public readonly extensions: IExtensionDescription[], + ) { } +} + +export type ResolvedExtensions = ResolverExtensions | LocalExtensions | RemoteExtensions; + export interface IExtensionHostFactory { createExtensionHost(runningLocations: ExtensionRunningLocationTracker, runningLocation: ExtensionRunningLocation, isInitialStart: boolean): IExtensionHost | null; } @@ -1300,6 +1343,10 @@ class DeltaExtensionsQueueItem { ) { } } +export function isResolverExtension(extension: IExtensionDescription): boolean { + return !!extension.activationEvents?.some(activationEvent => activationEvent.startsWith('onResolveRemoteAuthority:')); +} + /** * @argument extensions The extensions to be checked. * @argument ignoreWorkspaceTrust Do not take workspace trust into account. diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index ba08a5f4130..e4c9ad45922 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -70,6 +70,7 @@ export interface IExtensionPoint { readonly name: string; setHandler(handler: IExtensionPointHandler): IDisposable; readonly defaultExtensionKind: ExtensionKind[] | undefined; + readonly canHandleResolver?: boolean; } export class ExtensionPointUserDelta { @@ -109,14 +110,16 @@ export class ExtensionPoint implements IExtensionPoint { public readonly name: string; public readonly defaultExtensionKind: ExtensionKind[] | undefined; + public readonly canHandleResolver?: boolean; private _handler: IExtensionPointHandler | null; private _users: IExtensionPointUser[] | null; private _delta: ExtensionPointUserDelta | null; - constructor(name: string, defaultExtensionKind: ExtensionKind[] | undefined) { + constructor(name: string, defaultExtensionKind: ExtensionKind[] | undefined, canHandleResolver?: boolean) { this.name = name; this.defaultExtensionKind = defaultExtensionKind; + this.canHandleResolver = canHandleResolver; this._handler = null; this._users = null; this._delta = null; @@ -613,6 +616,7 @@ export interface IExtensionPointDescriptor { deps?: IExtensionPoint[]; jsonSchema: IJSONSchema; defaultExtensionKind?: ExtensionKind[]; + canHandleResolver?: boolean; /** * A function which runs before the extension point has been validated and which * should collect automatic activation events from the contribution. @@ -628,7 +632,7 @@ export class ExtensionsRegistryImpl { if (this._extensionPoints.has(desc.extensionPoint)) { throw new Error('Duplicate extension point: ' + desc.extensionPoint); } - const result = new ExtensionPoint(desc.extensionPoint, desc.defaultExtensionKind); + const result = new ExtensionPoint(desc.extensionPoint, desc.defaultExtensionKind, desc.canHandleResolver); this._extensionPoints.set(desc.extensionPoint, result); if (desc.activationEventsGenerator) { ImplicitActivationEvents.register(desc.extensionPoint, desc.activationEventsGenerator); diff --git a/src/vs/workbench/services/extensions/electron-sandbox/nativeExtensionService.ts b/src/vs/workbench/services/extensions/electron-sandbox/nativeExtensionService.ts index 551ea511448..8adc7c19fe7 100644 --- a/src/vs/workbench/services/extensions/electron-sandbox/nativeExtensionService.ts +++ b/src/vs/workbench/services/extensions/electron-sandbox/nativeExtensionService.ts @@ -40,7 +40,7 @@ import { IWorkspaceTrustManagementService } from '../../../../platform/workspace import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; import { EnablementState, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../extensionManagement/common/extensionManagement.js'; import { IWebWorkerExtensionHostDataProvider, IWebWorkerExtensionHostInitData, WebWorkerExtensionHost } from '../browser/webWorkerExtensionHost.js'; -import { AbstractExtensionService, ExtensionHostCrashTracker, IExtensionHostFactory, ResolvedExtensions, checkEnabledAndProposedAPI, extensionIsEnabled } from '../common/abstractExtensionService.js'; +import { AbstractExtensionService, ExtensionHostCrashTracker, IExtensionHostFactory, LocalExtensions, RemoteExtensions, ResolvedExtensions, ResolverExtensions, checkEnabledAndProposedAPI, extensionIsEnabled, isResolverExtension } from '../common/abstractExtensionService.js'; import { ExtensionDescriptionRegistrySnapshot } from '../common/extensionDescriptionRegistry.js'; import { parseExtensionDevOptions } from '../common/extensionDevOptions.js'; import { ExtensionHostKind, ExtensionRunningPreference, IExtensionHostKindPicker, extensionHostKindToString, extensionRunningPreferenceToString } from '../common/extensionHostKind.js'; @@ -58,6 +58,7 @@ import { IHostService } from '../../host/browser/host.js'; import { ILifecycleService, LifecyclePhase } from '../../lifecycle/common/lifecycle.js'; import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; import { IRemoteExplorerService } from '../../remote/common/remoteExplorerService.js'; +import { AsyncIterableEmitter, AsyncIterableObject } from '../../../../base/common/async.js'; export class NativeExtensionService extends AbstractExtensionService implements IExtensionService { @@ -103,6 +104,7 @@ export class NativeExtensionService extends AbstractExtensionService implements logService ); super( + { hasLocalProcess: true, allowRemoteExtensionsInLocalWebWorker: false }, extensionsProposedApi, extensionHostFactory, new NativeExtensionHostKindPicker(environmentService, configurationService, logService), @@ -316,7 +318,11 @@ export class NativeExtensionService extends AbstractExtensionService implements throw new Error(`Cannot get canonical URI because no extension is installed to resolve ${getRemoteAuthorityPrefix(remoteAuthority)}`); } - protected async _resolveExtensions(): Promise { + protected _resolveExtensions(): AsyncIterable { + return new AsyncIterableObject(emitter => this._doResolveExtensions(emitter)); + } + + private async _doResolveExtensions(emitter: AsyncIterableEmitter): Promise { this._extensionScanner.startScanningExtensions(); const remoteAuthority = this._environmentService.remoteAuthority; @@ -358,6 +364,12 @@ export class NativeExtensionService extends AbstractExtensionService implements this._logService.info(`Finished waiting on IWorkspaceTrustManagementService.workspaceResolved.`); } + const localExtensions = await this._scanAllLocalExtensions(); + const resolverExtensions = localExtensions.filter(extension => isResolverExtension(extension)); + if (resolverExtensions.length) { + emitter.emitOne(new ResolverExtensions(resolverExtensions)); + } + let resolverResult: ResolverResult; try { resolverResult = await this._resolveAuthorityInitial(remoteAuthority); @@ -372,7 +384,7 @@ export class NativeExtensionService extends AbstractExtensionService implements this._remoteAuthorityResolverService._setResolvedAuthorityError(remoteAuthority, err); // Proceed with the local extension host - return this._startLocalExtensionHost(); + return this._startLocalExtensionHost(emitter); } // set the resolved authority @@ -399,7 +411,7 @@ export class NativeExtensionService extends AbstractExtensionService implements if (!remoteEnv) { this._notificationService.notify({ severity: Severity.Error, message: nls.localize('getEnvironmentFailure', "Could not fetch remote environment") }); // Proceed with the local extension host - return this._startLocalExtensionHost(); + return this._startLocalExtensionHost(emitter); } updateProxyConfigurationsScope(remoteEnv.useHostProxy ? ConfigurationScope.APPLICATION : ConfigurationScope.MACHINE); @@ -409,15 +421,19 @@ export class NativeExtensionService extends AbstractExtensionService implements } - return this._startLocalExtensionHost(remoteExtensions); + return this._startLocalExtensionHost(emitter, remoteExtensions); } - private async _startLocalExtensionHost(remoteExtensions: IExtensionDescription[] = []): Promise { + private async _startLocalExtensionHost(emitter: AsyncIterableEmitter, remoteExtensions: IExtensionDescription[] = []): Promise { // Ensure that the workspace trust state has been fully initialized so // that the extension host can start with the correct set of extensions. await this._workspaceTrustManagementService.workspaceTrustInitialized; - return new ResolvedExtensions(await this._scanAllLocalExtensions(), remoteExtensions, /*hasLocalProcess*/true, /*allowRemoteExtensionsInLocalWebWorker*/false); + if (remoteExtensions.length) { + emitter.emitOne(new RemoteExtensions(remoteExtensions)); + } + + emitter.emitOne(new LocalExtensions(await this._scanAllLocalExtensions())); } protected async _onExtensionHostExit(code: number): Promise { 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 5e94ff70590..89b97f4ee7c 100644 --- a/src/vs/workbench/services/extensions/test/browser/extensionService.test.ts +++ b/src/vs/workbench/services/extensions/test/browser/extensionService.test.ts @@ -163,6 +163,7 @@ suite('ExtensionService', () => { } }; super( + { allowRemoteExtensionsInLocalWebWorker: false, hasLocalProcess: true }, extensionsProposedApi, extensionHostFactory, null!, @@ -209,7 +210,7 @@ suite('ExtensionService', () => { } }; } - protected _resolveExtensions(): Promise { + protected _resolveExtensions(): AsyncIterable { throw new Error('Method not implemented.'); } protected _scanSingleExtension(extension: IExtension): Promise { diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index a61367ba04a..01eb0d2a73b 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -45,7 +45,7 @@ export const enum LayoutSettings { EDITOR_TABS_MODE = 'workbench.editor.showTabs', EDITOR_ACTIONS_LOCATION = 'workbench.editor.editorActionsLocation', COMMAND_CENTER = 'window.commandCenter', - LAYOUT_ACTIONS = 'workbench.layoutControl.enabled', + LAYOUT_ACTIONS = 'workbench.layoutControl.enabled' } export const enum ActivityBarPosition { @@ -319,7 +319,6 @@ export interface IWorkbenchLayoutService extends ILayoutService { } export function shouldShowCustomTitleBar(configurationService: IConfigurationService, window: Window, menuBarToggled?: boolean): boolean { - if (!hasCustomTitlebar(configurationService)) { return false; } @@ -376,6 +375,7 @@ export function shouldShowCustomTitleBar(configurationService: IConfigurationSer } function isTitleBarEmpty(configurationService: IConfigurationService): boolean { + // with the command center enabled, we should always show if (configurationService.getValue(LayoutSettings.COMMAND_CENTER)) { return false; diff --git a/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts b/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts index 94a284248bf..2620e9a4200 100644 --- a/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts +++ b/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { canASAR, importAMDNodeModule, resolveAmdNodeModulePath } from '../../../../amdX.js'; -import * as dom from '../../../../base/browser/dom.js'; +import * as domStylesheets from '../../../../base/browser/domStylesheets.js'; import { equals as equalArray } from '../../../../base/common/arrays.js'; import { Color } from '../../../../base/common/color.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; @@ -75,7 +75,7 @@ export class TextMateTokenizationFeature extends Disposable implements ITextMate ) { super(); - this._styleElement = dom.createStyleSheet(); + this._styleElement = domStylesheets.createStyleSheet(); this._styleElement.className = 'vscode-tokens-styles'; grammarsExtPoint.setHandler((extensions) => this._handleGrammarsExtPoint(extensions)); diff --git a/src/vs/workbench/services/themes/browser/fileIconThemeData.ts b/src/vs/workbench/services/themes/browser/fileIconThemeData.ts index 4aefe5336d2..c60faca2144 100644 --- a/src/vs/workbench/services/themes/browser/fileIconThemeData.ts +++ b/src/vs/workbench/services/themes/browser/fileIconThemeData.ts @@ -10,11 +10,12 @@ import * as resources from '../../../../base/common/resources.js'; import * as Json from '../../../../base/common/json.js'; import { ExtensionData, IThemeExtensionPoint, IWorkbenchFileIconTheme } from '../common/workbenchThemeService.js'; import { getParseErrorMessage } from '../../../../base/common/jsonErrorMessages.js'; -import { asCSSUrl } from '../../../../base/browser/cssValue.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IExtensionResourceLoaderService } from '../../../../platform/extensionResourceLoader/common/extensionResourceLoader.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; -import { mainWindow } from '../../../../base/browser/window.js'; +import { fontCharacterRegex, fontColorRegex, fontSizeRegex } from '../common/productIconThemeSchema.js'; +import * as css from '../../../../base/browser/cssValue.js'; +import { fileIconSelectorEscape } from '../../../../editor/common/services/getIconClasses.js'; export class FileIconThemeData implements IWorkbenchFileIconTheme { @@ -236,7 +237,7 @@ export class FileIconThemeLoader { if (!iconThemeDocument.iconDefinitions) { return result; } - const selectorByDefinitionId: { [def: string]: string[] } = {}; + const selectorByDefinitionId: { [def: string]: css.Builder } = {}; const coveredLanguages: { [languageId: string]: boolean } = {}; const iconThemeDocumentLocationDirname = resources.dirname(iconThemeDocumentLocation); @@ -244,32 +245,32 @@ export class FileIconThemeLoader { return resources.joinPath(iconThemeDocumentLocationDirname, path); } - function collectSelectors(associations: IconsAssociation | undefined, baseThemeClassName?: string) { - function addSelector(selector: string, defId: string) { + function collectSelectors(associations: IconsAssociation | undefined, baseThemeClassName?: css.CssFragment) { + function addSelector(selector: css.CssFragment, defId: string) { if (defId) { let list = selectorByDefinitionId[defId]; if (!list) { - list = selectorByDefinitionId[defId] = []; + list = selectorByDefinitionId[defId] = new css.Builder(); } list.push(selector); } } if (associations) { - let qualifier = '.show-file-icons'; + let qualifier = css.inline`.show-file-icons`; if (baseThemeClassName) { - qualifier = baseThemeClassName + ' ' + qualifier; + qualifier = css.inline`${baseThemeClassName} ${qualifier}`; } - const expanded = '.monaco-tl-twistie.collapsible:not(.collapsed) + .monaco-tl-contents'; + const expanded = css.inline`.monaco-tl-twistie.collapsible:not(.collapsed) + .monaco-tl-contents`; if (associations.folder) { - addSelector(`${qualifier} .folder-icon::before`, associations.folder); + addSelector(css.inline`${qualifier} .folder-icon::before`, associations.folder); result.hasFolderIcons = true; } if (associations.folderExpanded) { - addSelector(`${qualifier} ${expanded} .folder-icon::before`, associations.folderExpanded); + addSelector(css.inline`${qualifier} ${expanded} .folder-icon::before`, associations.folderExpanded); result.hasFolderIcons = true; } @@ -277,37 +278,37 @@ export class FileIconThemeLoader { const rootFolderExpanded = associations.rootFolderExpanded || associations.folderExpanded; if (rootFolder) { - addSelector(`${qualifier} .rootfolder-icon::before`, rootFolder); + addSelector(css.inline`${qualifier} .rootfolder-icon::before`, rootFolder); result.hasFolderIcons = true; } if (rootFolderExpanded) { - addSelector(`${qualifier} ${expanded} .rootfolder-icon::before`, rootFolderExpanded); + addSelector(css.inline`${qualifier} ${expanded} .rootfolder-icon::before`, rootFolderExpanded); result.hasFolderIcons = true; } if (associations.file) { - addSelector(`${qualifier} .file-icon::before`, associations.file); + addSelector(css.inline`${qualifier} .file-icon::before`, associations.file); result.hasFileIcons = true; } const folderNames = associations.folderNames; if (folderNames) { for (const key in folderNames) { - const selectors: string[] = []; + const selectors = new css.Builder(); const name = handleParentFolder(key.toLowerCase(), selectors); - selectors.push(`.${escapeCSS(name)}-name-folder-icon`); - addSelector(`${qualifier} ${selectors.join('')}.folder-icon::before`, folderNames[key]); + selectors.push(css.inline`.${classSelectorPart(name)}-name-folder-icon`); + addSelector(css.inline`${qualifier} ${selectors.join('')}.folder-icon::before`, folderNames[key]); result.hasFolderIcons = true; } } const folderNamesExpanded = associations.folderNamesExpanded; if (folderNamesExpanded) { for (const key in folderNamesExpanded) { - const selectors: string[] = []; + const selectors = new css.Builder(); const name = handleParentFolder(key.toLowerCase(), selectors); - selectors.push(`.${escapeCSS(name)}-name-folder-icon`); - addSelector(`${qualifier} ${expanded} ${selectors.join('')}.folder-icon::before`, folderNamesExpanded[key]); + selectors.push(css.inline`.${classSelectorPart(name)}-name-folder-icon`); + addSelector(css.inline`${qualifier} ${expanded} ${selectors.join('')}.folder-icon::before`, folderNamesExpanded[key]); result.hasFolderIcons = true; } } @@ -316,7 +317,7 @@ export class FileIconThemeLoader { if (rootFolderNames) { for (const key in rootFolderNames) { const name = key.toLowerCase(); - addSelector(`${qualifier} .${escapeCSS(name)}-root-name-folder-icon.rootfolder-icon::before`, rootFolderNames[key]); + addSelector(css.inline`${qualifier} .${classSelectorPart(name)}-root-name-folder-icon.rootfolder-icon::before`, rootFolderNames[key]); result.hasFolderIcons = true; } } @@ -324,7 +325,7 @@ export class FileIconThemeLoader { if (rootFolderNamesExpanded) { for (const key in rootFolderNamesExpanded) { const name = key.toLowerCase(); - addSelector(`${qualifier} ${expanded} .${escapeCSS(name)}-root-name-folder-icon.rootfolder-icon::before`, rootFolderNamesExpanded[key]); + addSelector(css.inline`${qualifier} ${expanded} .${classSelectorPart(name)}-root-name-folder-icon.rootfolder-icon::before`, rootFolderNamesExpanded[key]); result.hasFolderIcons = true; } } @@ -335,7 +336,7 @@ export class FileIconThemeLoader { languageIds.jsonc = languageIds.json; } for (const languageId in languageIds) { - addSelector(`${qualifier} .${escapeCSS(languageId)}-lang-file-icon.file-icon::before`, languageIds[languageId]); + addSelector(css.inline`${qualifier} .${classSelectorPart(languageId)}-lang-file-icon.file-icon::before`, languageIds[languageId]); result.hasFileIcons = true; hasSpecificFileIcons = true; coveredLanguages[languageId] = true; @@ -344,16 +345,16 @@ export class FileIconThemeLoader { const fileExtensions = associations.fileExtensions; if (fileExtensions) { for (const key in fileExtensions) { - const selectors: string[] = []; + const selectors = new css.Builder(); const name = handleParentFolder(key.toLowerCase(), selectors); const segments = name.split('.'); if (segments.length) { for (let i = 0; i < segments.length; i++) { - selectors.push(`.${escapeCSS(segments.slice(i).join('.'))}-ext-file-icon`); + selectors.push(css.inline`.${classSelectorPart(segments.slice(i).join('.'))}-ext-file-icon`); } - selectors.push('.ext-file-icon'); // extra segment to increase file-ext score + selectors.push(css.inline`.ext-file-icon`); // extra segment to increase file-ext score } - addSelector(`${qualifier} ${selectors.join('')}.file-icon::before`, fileExtensions[key]); + addSelector(css.inline`${qualifier} ${selectors.join('')}.file-icon::before`, fileExtensions[key]); result.hasFileIcons = true; hasSpecificFileIcons = true; } @@ -361,18 +362,18 @@ export class FileIconThemeLoader { const fileNames = associations.fileNames; if (fileNames) { for (const key in fileNames) { - const selectors: string[] = []; + const selectors = new css.Builder(); const fileName = handleParentFolder(key.toLowerCase(), selectors); - selectors.push(`.${escapeCSS(fileName)}-name-file-icon`); - selectors.push('.name-file-icon'); // extra segment to increase file-name score + selectors.push(css.inline`.${classSelectorPart(fileName)}-name-file-icon`); + selectors.push(css.inline`.name-file-icon`); // extra segment to increase file-name score const segments = fileName.split('.'); if (segments.length) { for (let i = 1; i < segments.length; i++) { - selectors.push(`.${escapeCSS(segments.slice(i).join('.'))}-ext-file-icon`); + selectors.push(css.inline`.${classSelectorPart(segments.slice(i).join('.'))}-ext-file-icon`); } - selectors.push('.ext-file-icon'); // extra segment to increase file-ext score + selectors.push(css.inline`.ext-file-icon`); // extra segment to increase file-ext score } - addSelector(`${qualifier} ${selectors.join('')}.file-icon::before`, fileNames[key]); + addSelector(css.inline`${qualifier} ${selectors.join('')}.file-icon::before`, fileNames[key]); result.hasFileIcons = true; hasSpecificFileIcons = true; } @@ -380,9 +381,9 @@ export class FileIconThemeLoader { } } collectSelectors(iconThemeDocument); - collectSelectors(iconThemeDocument.light, '.vs'); - collectSelectors(iconThemeDocument.highContrast, '.hc-black'); - collectSelectors(iconThemeDocument.highContrast, '.hc-light'); + collectSelectors(iconThemeDocument.light, css.inline`.vs`); + collectSelectors(iconThemeDocument.highContrast, css.inline`.hc-black`); + collectSelectors(iconThemeDocument.highContrast, css.inline`.hc-light`); if (!result.hasFileIcons && !result.hasFolderIcons) { return result; @@ -390,52 +391,53 @@ export class FileIconThemeLoader { const showLanguageModeIcons = iconThemeDocument.showLanguageModeIcons === true || (hasSpecificFileIcons && iconThemeDocument.showLanguageModeIcons !== false); - const cssRules: string[] = []; + const cssRules = new css.Builder(); const fonts = iconThemeDocument.fonts; const fontSizes = new Map(); if (Array.isArray(fonts)) { const defaultFontSize = this.tryNormalizeFontSize(fonts[0].size) || '150%'; fonts.forEach(font => { - const src = font.src.map(l => `${asCSSUrl(resolvePath(l.path))} format('${l.format}')`).join(', '); - cssRules.push(`@font-face { src: ${src}; font-family: '${font.id}'; font-weight: ${font.weight}; font-style: ${font.style}; font-display: block; }`); + const fontSrcs = new css.Builder(); + fontSrcs.push(...font.src.map(l => css.inline`${css.asCSSUrl(resolvePath(l.path))} format(${css.stringValue(l.format)})`)); + cssRules.push(css.inline`@font-face { src: ${fontSrcs.join(', ')}; font-family: ${css.stringValue(font.id)}; font-weight: ${css.identValue(font.weight)}; font-style: ${css.identValue(font.style)}; font-display: block; }`); const fontSize = this.tryNormalizeFontSize(font.size); if (fontSize !== undefined && fontSize !== defaultFontSize) { fontSizes.set(font.id, fontSize); } }); - cssRules.push(`.show-file-icons .file-icon::before, .show-file-icons .folder-icon::before, .show-file-icons .rootfolder-icon::before { font-family: '${fonts[0].id}'; font-size: ${defaultFontSize}; }`); + cssRules.push(css.inline`.show-file-icons .file-icon::before, .show-file-icons .folder-icon::before, .show-file-icons .rootfolder-icon::before { font-family: ${css.stringValue(fonts[0].id)}; font-size: ${css.sizeValue(defaultFontSize)}; }`); } // Use emQuads to prevent the icon from collapsing to zero height for image icons - const emQuad = '\\2001'; + const emQuad = css.stringValue('\\2001'); for (const defId in selectorByDefinitionId) { const selectors = selectorByDefinitionId[defId]; const definition = iconThemeDocument.iconDefinitions[defId]; if (definition) { if (definition.iconPath) { - cssRules.push(`${selectors.join(', ')} { content: '${emQuad}'; background-image: ${asCSSUrl(resolvePath(definition.iconPath))}; }`); + cssRules.push(css.inline`${selectors.join(', ')} { content: ${emQuad}; background-image: ${css.asCSSUrl(resolvePath(definition.iconPath))}; }`); } else if (definition.fontCharacter || definition.fontColor) { - const body = []; - if (definition.fontColor) { - body.push(`color: ${definition.fontColor};`); + const body = new css.Builder(); + if (definition.fontColor && definition.fontColor.match(fontColorRegex)) { + body.push(css.inline`color: ${css.hexColorValue(definition.fontColor)};`); } - if (definition.fontCharacter) { - body.push(`content: '${definition.fontCharacter}';`); + if (definition.fontCharacter && definition.fontCharacter.match(fontCharacterRegex)) { + body.push(css.inline`content: ${css.stringValue(definition.fontCharacter)};`); } const fontSize = definition.fontSize ?? (definition.fontId ? fontSizes.get(definition.fontId) : undefined); - if (fontSize) { - body.push(`font-size: ${fontSize};`); + if (fontSize && fontSize.match(fontSizeRegex)) { + body.push(css.inline`font-size: ${css.sizeValue(fontSize)};`); } if (definition.fontId) { - body.push(`font-family: ${definition.fontId};`); + body.push(css.inline`font-family: ${css.stringValue(definition.fontId)};`); } if (showLanguageModeIcons) { - body.push(`background-image: unset;`); // potentially set by the language default + body.push(css.inline`background-image: unset;`); // potentially set by the language default } - cssRules.push(`${selectors.join(', ')} { ${body.join(' ')} }`); + cssRules.push(css.inline`${selectors.join(', ')} { ${body.join(' ')} }`); } } } @@ -445,9 +447,9 @@ export class FileIconThemeLoader { if (!coveredLanguages[languageId]) { const icon = this.languageService.getIcon(languageId); if (icon) { - const selector = `.show-file-icons .${escapeCSS(languageId)}-lang-file-icon.file-icon::before`; - cssRules.push(`${selector} { content: '${emQuad}'; background-image: ${asCSSUrl(icon.dark)}; }`); - cssRules.push(`.vs ${selector} { content: '${emQuad}'; background-image: ${asCSSUrl(icon.light)}; }`); + const selector = css.inline`.show-file-icons .${classSelectorPart(languageId)}-lang-file-icon.file-icon::before`; + cssRules.push(css.inline`${selector} { content: ${emQuad}; background-image: ${css.asCSSUrl(icon.dark)}; }`); + cssRules.push(css.inline`.vs ${selector} { content: ${emQuad}; background-image: ${css.asCSSUrl(icon.light)}; }`); } } } @@ -480,17 +482,17 @@ export class FileIconThemeLoader { } } -function handleParentFolder(key: string, selectors: string[]): string { +function handleParentFolder(key: string, selectors: css.Builder): string { const lastIndexOfSlash = key.lastIndexOf('/'); if (lastIndexOfSlash >= 0) { const parentFolder = key.substring(0, lastIndexOfSlash); - selectors.push(`.${escapeCSS(parentFolder)}-name-dir-icon`); + selectors.push(css.inline`.${classSelectorPart(parentFolder)}-name-dir-icon`); return key.substring(lastIndexOfSlash + 1); } return key; } -function escapeCSS(str: string) { - str = str.replace(/[\s]/g, '/'); // HTML class names can not contain certain whitespace characters (https://dom.spec.whatwg.org/#interface-domtokenlist), use / instead, which doesn't exist in file names. - return mainWindow.CSS.escape(str); +function classSelectorPart(str: string): css.CssFragment { + str = fileIconSelectorEscape(str); + return css.className(str, true); } diff --git a/src/vs/workbench/services/themes/browser/productIconThemeData.ts b/src/vs/workbench/services/themes/browser/productIconThemeData.ts index 0781c91a3a0..8961bfe3b2b 100644 --- a/src/vs/workbench/services/themes/browser/productIconThemeData.ts +++ b/src/vs/workbench/services/themes/browser/productIconThemeData.ts @@ -11,7 +11,7 @@ import * as Json from '../../../../base/common/json.js'; import { ExtensionData, IThemeExtensionPoint, IWorkbenchProductIconTheme, ThemeSettingDefaults } from '../common/workbenchThemeService.js'; import { getParseErrorMessage } from '../../../../base/common/jsonErrorMessages.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { fontIdRegex, fontWeightRegex, fontStyleRegex, fontFormatRegex } from '../common/productIconThemeSchema.js'; +import { fontIdRegex, fontWeightRegex, fontStyleRegex, fontFormatRegex, fontCharacterRegex } from '../common/productIconThemeSchema.js'; import { isObject, isString } from '../../../../base/common/types.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IconDefinition, getIconRegistry, IconContribution, IconFontDefinition, IconFontSource } from '../../../../platform/theme/common/iconRegistry.js'; @@ -200,8 +200,8 @@ function _loadProductIconThemeDocument(fileService: IExtensionResourceLoaderServ const sanitizedFonts: Map = new Map(); for (const font of contentValue.fonts) { - if (isString(font.id) && font.id.match(fontIdRegex)) { - const fontId = font.id; + const fontId = font.id; + if (isString(fontId) && fontId.match(fontIdRegex)) { let fontWeight = undefined; if (isString(font.weight) && font.weight.match(fontWeightRegex)) { @@ -245,7 +245,7 @@ function _loadProductIconThemeDocument(fileService: IExtensionResourceLoaderServ for (const iconId in contentValue.iconDefinitions) { const definition = contentValue.iconDefinitions[iconId]; - if (isString(definition.fontCharacter)) { + if (isString(definition.fontCharacter) && definition.fontCharacter.match(fontCharacterRegex)) { const fontId = definition.fontId ?? primaryFontId; const fontDefinition = sanitizedFonts.get(fontId); if (fontDefinition) { @@ -256,7 +256,7 @@ function _loadProductIconThemeDocument(fileService: IExtensionResourceLoaderServ warnings.push(nls.localize('error.icon.font', 'Skipping icon definition \'{0}\'. Unknown font.', iconId)); } } else { - warnings.push(nls.localize('error.icon.fontCharacter', 'Skipping icon definition \'{0}\'. Unknown fontCharacter.', iconId)); + warnings.push(nls.localize('error.icon.fontCharacter', 'Skipping icon definition \'{0}\'. Unknown fontCharacter. Must use a sing; character or a \\ followed by a Unicode code points in hexadecimal.', iconId)); } } return { iconDefinitions }; diff --git a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts index 857cf6f5415..a74a0b263c2 100644 --- a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts @@ -18,7 +18,7 @@ import { Event, Emitter } from '../../../../base/common/event.js'; import { registerFileIconThemeSchemas } from '../common/fileIconThemeSchema.js'; import { IDisposable, dispose, Disposable } from '../../../../base/common/lifecycle.js'; import { FileIconThemeData, FileIconThemeLoader } from './fileIconThemeData.js'; -import { createStyleSheet } from '../../../../base/browser/dom.js'; +import { createStyleSheet } from '../../../../base/browser/domStylesheets.js'; import { IBrowserWorkbenchEnvironmentService } from '../../environment/browser/environmentService.js'; import { IFileService, FileChangeType } from '../../../../platform/files/common/files.js'; import { URI } from '../../../../base/common/uri.js'; diff --git a/src/vs/workbench/services/themes/common/fileIconThemeSchema.ts b/src/vs/workbench/services/themes/common/fileIconThemeSchema.ts index 4d18093f41e..685c3375f00 100644 --- a/src/vs/workbench/services/themes/common/fileIconThemeSchema.ts +++ b/src/vs/workbench/services/themes/common/fileIconThemeSchema.ts @@ -7,7 +7,7 @@ import * as nls from '../../../../nls.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; -import { fontWeightRegex, fontStyleRegex, fontSizeRegex, fontIdRegex } from './productIconThemeSchema.js'; +import { fontWeightRegex, fontStyleRegex, fontSizeRegex, fontIdRegex, fontCharacterRegex, fontColorRegex } from './productIconThemeSchema.js'; const schemaId = 'vscode://schemas/icon-theme'; const schema: IJSONSchema = { @@ -208,12 +208,15 @@ const schema: IJSONSchema = { }, fontCharacter: { type: 'string', - description: nls.localize('schema.fontCharacter', 'When using a glyph font: The character in the font to use.') + description: nls.localize('schema.fontCharacter', 'When using a glyph font: The character in the font to use.'), + pattern: fontCharacterRegex, + patternErrorMessage: nls.localize('schema.fontCharacter.formatError', 'The fontCharacter must be a single letter or a backslash and followed by unicode code points in hexadecimal.') }, fontColor: { type: 'string', format: 'color-hex', - description: nls.localize('schema.fontColor', 'When using a glyph font: The color to use.') + description: nls.localize('schema.fontColor', 'When using a glyph font: The color to use.'), + pattern: fontColorRegex }, fontSize: { type: 'string', diff --git a/src/vs/workbench/services/themes/common/iconExtensionPoint.ts b/src/vs/workbench/services/themes/common/iconExtensionPoint.ts index 29588b66234..0a58dbcdbbf 100644 --- a/src/vs/workbench/services/themes/common/iconExtensionPoint.ts +++ b/src/vs/workbench/services/themes/common/iconExtensionPoint.ts @@ -11,6 +11,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import * as resources from '../../../../base/common/resources.js'; import { IExtensionDescription } from '../../../../platform/extensions/common/extensions.js'; import { extname, posix } from '../../../../base/common/path.js'; +import { fontCharacterRegex } from './productIconThemeSchema.js'; interface IIconExtensionPoint { [id: string]: { @@ -53,7 +54,9 @@ const iconConfigurationExtPoint = ExtensionsRegistry.registerExtensionPoint()); readonly onDidChangeAccountStatus = this._onDidChangeAccountStatus.event; @@ -144,10 +144,9 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat } private updateAuthenticationProviders(): boolean { - this.logService.info('Settings Sync: Updating authentication providers. Authentication Providers from store:', this.userDataSyncStoreManagementService.userDataSyncStore?.authenticationProviders || [].map(({ id }) => id)); const oldValue = this._authenticationProviders; this._authenticationProviders = (this.userDataSyncStoreManagementService.userDataSyncStore?.authenticationProviders || []).filter(({ id }) => this.authenticationService.declaredProviders.some(provider => provider.id === id)); - this.logService.info('Settings Sync: Authentication providers updated', this._authenticationProviders.map(({ id }) => id)); + this.logService.trace('Settings Sync: Authentication providers updated', this._authenticationProviders.map(({ id }) => id)); return equals(oldValue, this._authenticationProviders, (a, b) => a.id === b.id); } @@ -189,6 +188,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat const initPromise = this.update('initialize'); this._register(this.authenticationService.onDidChangeDeclaredProviders(() => { if (this.updateAuthenticationProviders()) { + // Trigger update only after the initialization is done initPromise.finally(() => this.update('declared authentication providers changed')); } })); @@ -223,7 +223,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat } private async update(reason: string): Promise { - this.logService.info(`Settings Sync: Updating due to ${reason}`); + this.logService.trace(`Settings Sync: Updating due to ${reason}`); this.updateAuthenticationProviders(); await this.updateCurrentAccount(); @@ -237,18 +237,17 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat } private async updateCurrentAccount(): Promise { - this.logService.info('Settings Sync: Updating the current account'); + this.logService.trace('Settings Sync: Updating the current account'); const currentSessionId = this.currentSessionId; const currentAuthenticationProviderId = this.currentAuthenticationProviderId; if (currentSessionId) { const authenticationProviders = currentAuthenticationProviderId ? this.authenticationProviders.filter(({ id }) => id === currentAuthenticationProviderId) : this.authenticationProviders; - this.logService.info('Settings Sync: Updating the current account using current session', currentSessionId, currentAuthenticationProviderId, authenticationProviders.map(({ id }) => id)); for (const { id, scopes } of authenticationProviders) { const sessions = (await this.authenticationService.getSessions(id, scopes)) || []; for (const session of sessions) { if (session.id === currentSessionId) { this._current = new UserDataSyncAccount(id, session); - this.logService.info('Settings Sync: Updated the current account', this._current.accountName); + this.logService.trace('Settings Sync: Updated the current account', this._current.accountName); return; } } @@ -261,7 +260,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat let value: { token: string; authenticationProviderId: string } | undefined = undefined; if (current) { try { - this.logService.info('Settings Sync: Updating the token for the account', current.accountName); + this.logService.trace('Settings Sync: Updating the token for the account', current.accountName); const token = current.token; this.logService.info('Settings Sync: Token updated for the account', current.accountName); value = { token, authenticationProviderId: current.authenticationProviderId }; @@ -273,7 +272,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat } private updateAccountStatus(accountStatus: AccountStatus): void { - this.logService.info(`Settings Sync: Updating the account status to ${accountStatus}`); + this.logService.trace(`Settings Sync: Updating the account status to ${accountStatus}`); if (this._accountStatus !== accountStatus) { const previous = this._accountStatus; this.logService.info(`Settings Sync: Account status changed from ${previous} to ${accountStatus}`); diff --git a/src/vs/workbench/services/userDataSync/common/userDataSync.ts b/src/vs/workbench/services/userDataSync/common/userDataSync.ts index d0b966f4d0f..82baf1451c2 100644 --- a/src/vs/workbench/services/userDataSync/common/userDataSync.ts +++ b/src/vs/workbench/services/userDataSync/common/userDataSync.ts @@ -83,7 +83,7 @@ export const SYNC_VIEW_ICON = registerIcon('settings-sync-view-icon', Codicon.sy // Contexts export const CONTEXT_SYNC_STATE = new RawContextKey('syncStatus', SyncStatus.Uninitialized); export const CONTEXT_SYNC_ENABLEMENT = new RawContextKey('syncEnabled', false); -export const CONTEXT_ACCOUNT_STATE = new RawContextKey('userDataSyncAccountStatus', AccountStatus.Unavailable); +export const CONTEXT_ACCOUNT_STATE = new RawContextKey('userDataSyncAccountStatus', AccountStatus.Uninitialized); export const CONTEXT_ENABLE_ACTIVITY_VIEWS = new RawContextKey(`enableSyncActivityViews`, false); export const CONTEXT_ENABLE_SYNC_CONFLICTS_VIEW = new RawContextKey(`enableSyncConflictsView`, false); export const CONTEXT_HAS_CONFLICTS = new RawContextKey('hasConflicts', false); diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index a3fa926bf6b..216580b5e45 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -172,7 +172,6 @@ import './contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.js'; import './contrib/chat/electron-sandbox/chat.contribution.js'; import './contrib/inlineChat/electron-sandbox/inlineChat.contribution.js'; import './contrib/chat/browser/chatSetup.contribution.js'; -import './contrib/chat/browser/chatMovedView.contribution.js'; // Encryption import './contrib/encryption/electron-sandbox/encryption.contribution.js'; diff --git a/src/vscode-dts/vscode.proposed.inlineEdit.d.ts b/src/vscode-dts/vscode.proposed.inlineEdit.d.ts index 3bac065480a..9a5eabb9f8b 100644 --- a/src/vscode-dts/vscode.proposed.inlineEdit.d.ts +++ b/src/vscode-dts/vscode.proposed.inlineEdit.d.ts @@ -28,6 +28,8 @@ declare module 'vscode' { */ rejected?: Command; + shown?: Command; + commands?: Command[]; /** diff --git a/src/vscode-dts/vscode.proposed.notebookCellExecution.d.ts b/src/vscode-dts/vscode.proposed.notebookCellExecution.d.ts index 944f68f5fe3..341e91a32c4 100644 --- a/src/vscode-dts/vscode.proposed.notebookCellExecution.d.ts +++ b/src/vscode-dts/vscode.proposed.notebookCellExecution.d.ts @@ -19,15 +19,20 @@ declare module 'vscode' { } export interface CellExecutionError { + /** + * The error name. + */ + readonly name: string; + /** * The error message. */ readonly message: string; /** - * The error stack trace. + * The string from an Error object or parsed details on each stack frame to help with diagnostics. */ - readonly stack: string | undefined; + readonly stack: string | CellErrorStackFrame[] | undefined; /** * The cell resource which had the error. @@ -38,7 +43,30 @@ declare module 'vscode' { * The location within the resource where the error occurred. */ readonly location: Range | undefined; + } + export class CellErrorStackFrame { + /** + * The location of this stack frame. This should be provided as a URI if the + * location of the call frame can be accessed by the editor. + */ + readonly uri?: Uri; + /** + * Position of the stack frame within the file. + */ + position?: Position; + + /** + * The name of the stack frame, typically a method or function name. + */ + readonly label: string; + + /** + * @param label The name of the stack frame + * @param file The file URI of the stack frame + * @param position The position of the stack frame within the file + */ + constructor(label: string, uri?: Uri, position?: Position); } } diff --git a/src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts b/src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts new file mode 100644 index 00000000000..02dc22483d4 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // https://github.com/microsoft/vscode/issues/84899 + + export enum TextEditorDiffKind { + Addition = 1, + Deletion = 2, + Modification = 3 + } + + export interface TextEditorDiff { + readonly originalStartLineNumber: number; + readonly originalEndLineNumber: number; + readonly modifiedStartLineNumber: number; + readonly modifiedEndLineNumber: number; + readonly kind: TextEditorDiffKind; + } + + export interface TextEditorDiffInformation { + readonly documentVersion: number; + readonly original: Uri | undefined; + readonly modified: Uri | undefined; + readonly diff: readonly TextEditorDiff[]; + } + + export interface TextEditorDiffInformationChangeEvent { + readonly textEditor: TextEditor; + readonly diffInformation: TextEditorDiffInformation | undefined; + } + + export interface TextEditor { + readonly diffInformation: TextEditorDiffInformation | undefined; + } + + export namespace window { + export const onDidChangeTextEditorDiffInformation: Event; + } + +} diff --git a/src/vscode-dts/vscode.proposed.valueSelectionInQuickPick.d.ts b/src/vscode-dts/vscode.proposed.valueSelectionInQuickPick.d.ts new file mode 100644 index 00000000000..b6a3bf18684 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.valueSelectionInQuickPick.d.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // @CrafterKolyan https://github.com/microsoft/vscode/issues/233274 + + export interface QuickPick { + /** + * Selection range in the input value. Defined as tuple of two number where the + * first is the inclusive start index and the second the exclusive end index. When + * `undefined` the whole pre-filled value will be selected, when empty (start equals end) + * only the cursor will be set, otherwise the defined range will be selected. + * + * This property does not get updated when the user types or makes a selection, + * but it can be updated by the extension. + */ + valueSelection: readonly [number, number] | undefined; + } +}