diff --git a/build/npm/preinstall.ts b/build/npm/preinstall.ts index 54a377a65d22bc..8ce2578510e90b 100644 --- a/build/npm/preinstall.ts +++ b/build/npm/preinstall.ts @@ -46,8 +46,8 @@ const npmUserAgent = process.env.npm_config_user_agent; const npmVersionMatch = npmUserAgent?.match(/npm\/(\d+)\.(\d+)\.(\d+)/); if (npmVersionMatch) { const npmMajor = parseInt(npmVersionMatch[1]); - if (npmMajor >= 12) { - console.error(`\x1b[1;31m*** Please use npm version < 12.0.0. Currently using v${npmUserAgent}.\x1b[0;0m`); + if (npmMajor >= 13) { + console.error(`\x1b[1;31m*** Please use npm version < 13.0.0. Currently using v${npmUserAgent}.\x1b[0;0m`); throw new Error(); } } diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index c49b5352fd8c70..4cb1fb3aa54849 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -3879,7 +3879,18 @@ "default": true } }, - "github.copilot.chat.languageContext.typescript.items": { + "github.copilot.chat.languageContext.typescript7.enabled": { + "type": "boolean", + "default": false, + "scope": "resource", + "tags": [ + "experimental" + ], + "markdownDescription": "%github.copilot.chat.languageContext.typescript7.enabled%", + "agentsWindow": { + "default": false + } + }, "github.copilot.chat.languageContext.typescript.items": { "type": "string", "enum": [ "minimal", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 62ec03304482c8..463f36c4f2528d 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -253,6 +253,7 @@ "github.copilot.walkthrough.sparkle.media.altText": "The video shows the sparkle icon in the source control input box being clicked, triggering GitHub Copilot to generate a commit message automatically", "github.copilot.chat.completionContext.typescript.mode": "The execution mode of the TypeScript Copilot context provider.", "github.copilot.chat.languageContext.typescript.enabled": "Enables the TypeScript language context provider for inline suggestions", + "github.copilot.chat.languageContext.typescript7.enabled": "Enables the TypeScript language context provider for inline suggestions when using TS7 language services", "github.copilot.chat.languageContext.typescript.items": "Controls which kind of items are included in the TypeScript language context provider.", "github.copilot.chat.languageContext.typescript.includeDocumentation": "Controls whether to include documentation comments in the generated code snippets.", "github.copilot.chat.languageContext.typescript.cacheTimeout": "The cache population timeout for the TypeScript language context provider in milliseconds. The default is 500 milliseconds.", diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts index 1b68fa36c0b575..30d94eff4d5034 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts @@ -5,7 +5,6 @@ import * as vscode from 'vscode'; -import { LRUCache } from 'lru-cache'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { Copilot } from '../../../platform/inlineCompletions/common/api'; import { ILanguageContextProviderService, ProviderTarget } from '../../../platform/languageContextProvider/common/languageContextProviderService'; @@ -14,1595 +13,122 @@ import { ILogService } from '../../../platform/log/common/logService'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; import { Queue } from '../../../util/vs/base/common/async'; -import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { DisposableStore } from '../../../util/vs/base/common/lifecycle'; import { generateUuid } from '../../../util/vs/base/common/uuid'; -import * as protocol from '../common/serverProtocol'; import { InspectorDataProvider } from './inspector'; import { ThrottledDebouncer } from './throttledDebounce'; -import { ContextItemResultBuilder, ContextItemSummary, ResolvedRunnableResult, type OnCachePopulatedEvent, type OnContextComputedEvent, type OnContextComputedOnTimeoutEvent } from './types'; - -const currentTokenBudget: number = 8 * 1024; - -enum ExecutionTarget { - Semantic, - Syntax -} - -type ExecConfig = { - readonly lowPriority?: boolean; - readonly nonRecoverable?: boolean; - readonly cancelOnResourceChange?: vscode.Uri; - readonly executionTarget?: ExecutionTarget; -}; - -enum ErrorLocation { - Client = 'client', - Server = 'server' -} - -enum ErrorPart { - ServerPlugin = 'server-plugin', - TypescriptPlugin = 'typescript-plugin', - CopilotExtension = 'copilot-extension' -} - -interface TypeScriptServerError extends Error { - response: { - type: 'response'; - command: string; - message: string; - }; - version: { - displayName: string; - }; -} -namespace TypeScriptServerError { - export function is(value: Error): value is TypeScriptServerError { - const candidate = value as TypeScriptServerError; - return candidate instanceof Error && candidate.response !== undefined && candidate.version !== undefined && typeof candidate.version.displayName === 'string'; - } -} - -namespace RequestContext { - export function getSampleTelemetry(context: RequestContext): number { - return Math.max(1, Math.min(100, context.sampleTelemetry ?? 1)); - } -} - -class TelemetrySender { - - private readonly telemetryService: ITelemetryService; - private readonly logService: ILogService; - private sendRequestTelemetryCounter: number; - private sendSpeculativeRequestTelemetryCounter: number; - - constructor(telemetryService: ITelemetryService, logService: ILogService) { - this.telemetryService = telemetryService; - this.logService = logService; - this.sendRequestTelemetryCounter = 0; - this.sendSpeculativeRequestTelemetryCounter = 0; - } - - public sendSpeculativeRequestTelemetry(context: RequestContext, originalRequestId: string, numberOfItems: number): void { - const sampleTelemetry = RequestContext.getSampleTelemetry(context); - const shouldSendTelemetry = sampleTelemetry === 1 || this.sendSpeculativeRequestTelemetryCounter % sampleTelemetry === 0; - this.sendSpeculativeRequestTelemetryCounter++; - - if (shouldSendTelemetry) { - /* __GDPR__ - "typescript-context-plugin.completion-context.speculative" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "originalRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The original request id for which this is a speculative request" }, - "numberOfItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of items in the speculative request", "isMeasurement": true }, - "sampleTelemetry": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The sampling rate for telemetry. A value of 1 means every request is logged, a value of 5 means every 5th request is logged, etc.", "isMeasurement": true } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.speculative', - { - requestId: context.requestId, - source: context.source ?? KnownSources.unknown, - originalRequestId: originalRequestId - }, - { - numberOfItems: numberOfItems, - sampleTelemetry: sampleTelemetry - } - ); - } - this.logService.debug(`TypeScript Copilot context speculative request: [${context.requestId} - ${originalRequestId}, numberOfItems: ${numberOfItems}]`); - } - - public willLogRequestTelemetry(context: RequestContext): boolean { - const sampleTelemetry = RequestContext.getSampleTelemetry(context); - return sampleTelemetry === 1 || this.sendRequestTelemetryCounter % sampleTelemetry === 0; - } - - public sendRequestTelemetry(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, data: ContextItemSummary, timeTaken: number, cacheState: { before: CacheState; after: CacheState } | undefined, cacheRequest: string | undefined): void { - const stats = data.stats; - const nodePath = data?.path ? JSON.stringify(data.path) : JSON.stringify([0]); - const items = stats.items; - const totalSize = stats.totalSize; - const fileSize = document.getText().length; - - const sampleTelemetry = RequestContext.getSampleTelemetry(context); - const shouldSendTelemetry = sampleTelemetry === 1 || this.sendRequestTelemetryCounter % sampleTelemetry === 0; - this.sendRequestTelemetryCounter++; - if (shouldSendTelemetry) { - /* __GDPR__ - "typescript-context-plugin.completion-context.request" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "trigger": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The trigger kind of the request" }, - "cacheRequest": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache request that was used to populate the cache" }, - "nodePath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The syntax kind path to the AST node the position resolved to." }, - "cancelled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request got cancelled on the client side" }, - "timedOut": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request timed out on the server side" }, - "tokenBudgetExhausted": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the token budget was exhausted" }, - "serverTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken on the server side", "isMeasurement": true }, - "contextComputeTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken on the server side to compute the context", "isMeasurement": true }, - "timeTaken": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken to provide the completion", "isMeasurement": true }, - "total": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of context items", "isMeasurement": true }, - "snippets": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of code snippets", "isMeasurement": true }, - "traits": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of traits", "isMeasurement": true }, - "yielded": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of yielded items", "isMeasurement": true }, - "items": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Detailed information about each context item delivered." }, - "totalSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total size of all context items", "isMeasurement": true }, - "fileSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The size of the file", "isMeasurement": true }, - "cachedItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of cache items", "isMeasurement": true }, - "referencedItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of referenced items", "isMeasurement": true }, - "isSpeculative": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request was speculative" }, - "beforeCacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state before the request was sent" }, - "afterCacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state after the request was sent" }, - "fromCache": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the context was fully provided from cache" }, - "sampleTelemetry": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The sampling rate for telemetry. A value of 1 means every request is logged, a value of 5 means every 5th request is logged, etc.", "isMeasurement": true } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.request', - { - requestId: context.requestId, - opportunityId: context.opportunityId ?? 'unknown', - source: context.source ?? KnownSources.unknown, - trigger: context.trigger ?? TriggerKind.unknown, - cacheRequest: cacheRequest ?? 'unknown', - nodePath: nodePath, - cancelled: data.cancelled.toString(), - timedOut: data.timedOut.toString(), - tokenBudgetExhausted: data.tokenBudgetExhausted.toString(), - items: JSON.stringify(items), - isSpeculative: (context.proposedEdits !== undefined && context.proposedEdits.length > 0 ? true : false).toString(), - beforeCacheState: cacheState?.before.toString(), - afterCacheState: cacheState?.after.toString(), - fromCache: data.fromCache.toString(), - }, - { - serverTime: data.serverTime, - contextComputeTime: data.contextComputeTime, - timeTaken, - total: stats.total, - snippets: stats.snippets, - traits: stats.traits, - yielded: stats.yielded, - totalSize: totalSize, - fileSize: fileSize, - cachedItems: data.cachedItems, - referencedItems: data.referencedItems, - sampleTelemetry: sampleTelemetry - } - ); - } - this.logService.debug(`TypeScript Copilot context: [${context.requestId}, ${context.source ?? KnownSources.unknown}, ${JSON.stringify(position, undefined, 0)}, ${JSON.stringify(nodePath, undefined, 0)}, ${JSON.stringify(stats, undefined, 0)}, cacheItems:${data.cachedItems}, cacheState:${JSON.stringify(cacheState, undefined, 0)}, budgetExhausted:${data.tokenBudgetExhausted}, cancelled:${data.cancelled}, timedOut:${data.timedOut}, fileSize:${fileSize}] in [${timeTaken},${data.serverTime},${data.contextComputeTime}]ms.${data.timedOut ? ' Timed out.' : ''}`); - if (data.errorData !== undefined && data.errorData.length > 0) { - const errorData = data.errorData; - for (const error of errorData) { - /* __GDPR__ - "typescript-context-plugin.completion-context.error" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context errors", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "code": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The failure code", "isMeasurement": true }, - "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.error', - { - requestId: context.requestId, - source: context.source ?? KnownSources.unknown, - message: error.message - }, - { - code: error.code - } - ); - this.logService.error('Error computing context:', `${error.message} [${error.code}]`); - } - } - } - - public sendRequestOnTimeoutTelemetry(context: RequestContext, data: ContextItemSummary, cacheState: CacheState): void { - const stats = data.stats; - const items = stats.items; - const totalSize = stats.totalSize; - /* __GDPR__ - "typescript-context-plugin.completion-context.on-timeout" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context on timeout", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "total": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of context items", "isMeasurement": true }, - "snippets": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of code snippets", "isMeasurement": true }, - "traits": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of traits", "isMeasurement": true }, - "yielded": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of yielded items", "isMeasurement": true }, - "items": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Detailed information about each context item delivered." }, - "totalSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total size of all context items", "isMeasurement": true }, - "cacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state for the onTimeout request" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.on-timeout', - { - requestId: context.requestId, - opportunityId: context.opportunityId ?? 'unknown', - source: context.source ?? KnownSources.unknown, - items: JSON.stringify(items), - cacheState: cacheState.toString() - }, - { - total: stats.total, - snippets: stats.snippets, - traits: stats.traits, - yielded: stats.yielded, - totalSize: totalSize - } - ); - this.logService.debug(`TypeScript Copilot context on timeout: [${context.requestId}, ${JSON.stringify(stats, undefined, 0)}]`); - } - - public sendRequestFailureTelemetry(context: RequestContext, data: { error: protocol.ErrorCode; message: string; stack?: string }): void { - /* __GDPR__ - "typescript-context-plugin.completion-context.failed" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context in failure case", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "code:": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The failure code" }, - "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" }, - "stack": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure stack" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.failed', - { - requestId: context.requestId, - opportunityId: context.opportunityId ?? 'unknown', - source: context.source ?? KnownSources.unknown, - code: data.error, - message: data.message, - stack: data.stack ?? 'Not available' - } - ); - } - - public sendRequestCancelledTelemetry(context: RequestContext, timeTaken: number): void { - /* __GDPR__ - "typescript-context-plugin.completion-context.cancelled" : { - "owner": "dirkb", - "comment": "Telemetry for copilot inline completion context in cancellation case", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, - "timeTaken": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken to provide the completion", "isMeasurement": true } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.completion-context.cancelled', - { - requestId: context.requestId, - opportunityId: context.opportunityId ?? 'unknown', - source: context.source ?? KnownSources.unknown - }, - { - timeTaken: timeTaken - } - ); - this.logService.debug(`TypeScript Copilot context request ${context.requestId} got cancelled.`); - } - - public sendActivationTelemetry(response: protocol.PingResponse | undefined, error: unknown | undefined): void { - if (response !== undefined) { - const body: protocol.PingResponse['body'] | undefined = response?.body; - if (body?.kind === 'ok') { - /* __GDPR__ - "typescript-context-plugin.activation.ok" : { - "owner": "dirkb", - "comment": "Telemetry for TypeScript server plugin", - "session": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the TypeScript server had a session" }, - "supported": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the TypeScript server version is supported" }, - "version": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version of the TypeScript server" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.activation.ok', - { - session: body.session.toString(), - supported: body.supported.toString(), - version: body.version ?? 'unknown' - } - ); - } else if (body?.kind === 'error') { - this.sendActivationFailedTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, body.message, body.stack); - } else { - this.sendUnknownPingResponseTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, response); - } - } else if (error !== undefined) { - const isError = error instanceof Error; - if (isError && TypeScriptServerError.is(error)) { - this.sendActivationFailedTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, error.response.message ?? error.message, undefined, error.version.displayName); - } else if (isError) { - this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, error.message, error.stack); - } else { - this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, 'Unknown error', undefined); - } - } else { - this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, 'Neither response nor error received.', undefined); - } - } - - public sendActivationFailedTelemetry(location: ErrorLocation, part: ErrorPart, message: string, stack?: string | undefined, version?: string | undefined): void { - /* __GDPR__ - "typescript-context-plugin.activation.failed" : { - "owner": "dirkb", - "comment": "Telemetry for TypeScript server plugin", - "location": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The location of the failure" }, - "part": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The part that errored" }, - "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" }, - "stack": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure stack" }, - "version": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.activation.failed', - { - location: location, - part: part, - message: message, - stack: stack ?? 'Not available', - version: version ?? 'Not specified' - } - ); - } - - private sendUnknownPingResponseTelemetry(location: ErrorLocation, part: ErrorPart, response: object): void { - /* __GDPR__ - "typescript-context-plugin.activation.unknown-ping-response" : { - "owner": "dirkb", - "comment": "Telemetry for TypeScript server plugin", - "location": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The location of the failure" }, - "part": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The part that errored" }, - "response": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The response literal" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.activation.unknown-ping-response', - { - location: location, - part: part, - response: JSON.stringify(response, undefined, 0) - } - ); - } - - public sendIntegrationTelemetry(requestId: string, document: string, versionMismatch?: string): void { - /* __GDPR__ - "typescript-context-plugin.integration.failed" : { - "owner": "dirkb", - "comment": "Telemetry for Copilot inline chat integration.", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, - "document": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The document for which the integration failed" }, - "versionMismatch": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version mismatch" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.integration.failed', - { - requestId: requestId, - document: document, - versionMismatch: versionMismatch - } - ); - } - - public sendInlineCompletionProviderTelemetry(source: KnownSources, registered: boolean): void { - if (registered) { - /* __GDPR__ - "typescript-context-plugin.inline-completion-provider.registered" : { - "owner": "dirkb", - "comment": "Telemetry for Copilot inline completions", - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.inline-completion-provider.registered', - { - source: source - } - ); - } else { - /* __GDPR__ - "typescript-context-plugin.inline-completion-provider.unregistered" : { - "owner": "dirkb", - "comment": "Telemetry for Copilot inline completions", - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" } - } - */ - this.telemetryService.sendMSFTTelemetryEvent( - 'typescript-context-plugin.inline-completion-provider.unregistered', - { - source: source - } - ); - } - } -} - -type RequestInfo = { - readonly document: string; - readonly version: number; - readonly languageId: string; - readonly position: vscode.Position; - readonly requestId: string; - readonly path: number[]; -}; - -type ContextRequestState = { - client: readonly ResolvedRunnableResult[]; - clientOnTimeout: readonly ResolvedRunnableResult[]; - server: readonly protocol.CachedContextRunnableResult[]; - resultMap: Map; - itemMap: Map; -}; - -type CacheInfo = { - version: number; - state: CacheState; -}; - -enum CacheState { - NotPopulated = 'NotPopulated', - PartiallyPopulated = 'PartiallyPopulated', - FullyPopulated = 'FullyPopulated' -} - -type ManagerUpdateResult = { - resolved: ResolvedRunnableResult[]; - serverComputed: Set; - cached: number; - referenced: number; -}; - -class RunnableResultManager implements vscode.Disposable { - - private readonly disposables = new DisposableStore(); - private requestInfo: RequestInfo | undefined; - - private cacheInfo: CacheInfo; - private results: Map; - private readonly withInRangeRunnableResults: { resultId: protocol.ContextRunnableResultId; range: vscode.Range }[]; - private readonly outsideRangeRunnableResults: { resultId: protocol.ContextRunnableResultId; ranges: vscode.Range[] }[] = []; - private readonly neighborFileRunnableResults: { resultId: protocol.ContextRunnableResultId }[]; - - constructor() { - this.requestInfo = undefined; - this.results = new Map(); - - this.cacheInfo = { - version: 0, - state: CacheState.NotPopulated - }; - this.withInRangeRunnableResults = []; - this.outsideRangeRunnableResults = []; - this.neighborFileRunnableResults = []; - - this.disposables.add(vscode.workspace.onDidChangeTextDocument((event: vscode.TextDocumentChangeEvent) => { - if (this.requestInfo === undefined || event.contentChanges.length === 0) { - return; - } - if (event.document.uri.toString() !== this.requestInfo.document) { - if (this.affectsTypeScript(event)) { - this.clear(); - } - } else { - for (const change of event.contentChanges) { - const changeRange = change.range; - for (let i = 0; i < this.withInRangeRunnableResults.length;) { - const entry = this.withInRangeRunnableResults[i]; - if (entry.range.contains(changeRange)) { - entry.range = this.applyTextContentChangeEventToWithinRange(change, entry.range); - i++; - } else { - const id = entry.resultId; - this.results.delete(id); - this.withInRangeRunnableResults.splice(i, 1); - } - } - for (let i = 0; i < this.outsideRangeRunnableResults.length;) { - const entry = this.outsideRangeRunnableResults[i]; - const ranges = this.applyTextContentChangeEventToOutsideRanges(change, entry.ranges); - if (ranges === undefined) { - const id = entry.resultId; - this.results.delete(id); - this.outsideRangeRunnableResults.splice(i, 1); - } else { - entry.ranges = ranges; - i++; - } - } - this.cacheInfo.version = event.document.version; - } - } - })); - this.disposables.add(vscode.workspace.onDidCloseTextDocument((document: vscode.TextDocument) => { - if (this.requestInfo?.document === document.uri.toString()) { - this.clear(); - } - })); - this.disposables.add(vscode.window.onDidChangeActiveTextEditor(() => { - this.clear(); - })); - this.disposables.add(vscode.window.tabGroups.onDidChangeTabs((event: vscode.TabChangeEvent) => { - if (event.closed.length === 0 && event.opened.length === 0) { - return; - } - for (const item of this.neighborFileRunnableResults) { - this.results.delete(item.resultId); - } - this.neighborFileRunnableResults.length = 0; - })); - } - - public clear(): void { - this.requestInfo = undefined; - this.results.clear(); - - this.cacheInfo = { - version: 0, - state: CacheState.NotPopulated - }; - this.withInRangeRunnableResults.length = 0; - this.outsideRangeRunnableResults.length = 0; - this.neighborFileRunnableResults.length = 0; - } - - public getCacheState(): CacheState { - return this.cacheInfo.state; - } - - public update(document: vscode.TextDocument, version: number, position: vscode.Position, context: RequestContext, body: protocol.ComputeContextResponse.OK, requestState: ContextRequestState | undefined): ManagerUpdateResult { - const itemMap = requestState?.itemMap ?? new Map(); - const usedResults = requestState?.resultMap ?? new Map(); - - this.withInRangeRunnableResults.length = 0; - this.outsideRangeRunnableResults.length = 0; - this.neighborFileRunnableResults.length = 0; - this.results.clear(); - this.cacheInfo = { - version: version, - state: CacheState.NotPopulated - }; - - let cachedItems = 0; - let referencedItems = 0; - const serverComputed: Set = new Set(); - this.requestInfo = { - document: document.uri.toString(), - version: version, - languageId: document.languageId, - position: position, - requestId: context.requestId, - path: body.path ?? [0] - }; - - if (body.runnableResults === undefined || body.runnableResults.length === 0 || body.path === undefined || body.path.length === 0 || body.path[0] === 0) { - return { resolved: [], cached: cachedItems, referenced: referencedItems, serverComputed: serverComputed }; - } - - const serverItems: Set = new Set(); - // Add new client side context items to the item map. - if (body.contextItems !== undefined && body.contextItems.length > 0) { - for (const item of body.contextItems) { - if (protocol.ContextItem.hasKey(item)) { - itemMap.set(item.key, item); - serverItems.add(item.key); - } - } - } - const updateRunnableResult = (resultItem: protocol.ContextRunnableResultTypes): ResolvedRunnableResult | undefined => { - let result: ResolvedRunnableResult | undefined; - if (resultItem.kind === protocol.ContextRunnableResultKind.ComputedResult) { - serverComputed.add(resultItem.id); - const items: protocol.FullContextItem[] = []; - for (const contextItem of resultItem.items) { - if (contextItem.kind === protocol.ContextKind.Reference) { - const referenced: protocol.FullContextItem | undefined = itemMap.get(contextItem.key); - if (referenced !== undefined) { - referencedItems++; - items.push(referenced); - if (!serverItems.has(contextItem.key)) { - cachedItems++; - } - } - } else { - items.push(contextItem); - } - } - result = ResolvedRunnableResult.from(resultItem, items); - } else if (resultItem.kind === protocol.ContextRunnableResultKind.Reference) { - result = usedResults.get(resultItem.id); - if (result !== undefined) { - cachedItems += result.items.length; - } - } - if (result === undefined) { - return; - } - this.results.set(result.id, result); - if (result.cache !== undefined) { - if (result.cache.scope.kind === protocol.CacheScopeKind.WithinRange) { - const scopeRange = result.cache.scope.range; - const range = new vscode.Range(scopeRange.start.line, scopeRange.start.character, scopeRange.end.line, scopeRange.end.character); - this.withInRangeRunnableResults.push({ range, resultId: result.id }); - } else if (result.cache.scope.kind === protocol.CacheScopeKind.NeighborFiles) { - this.neighborFileRunnableResults.push({ resultId: result.id }); - } else if (result.cache.scope.kind === protocol.CacheScopeKind.OutsideRange) { - const ranges: vscode.Range[] = []; - for (const scopeRange of result.cache.scope.ranges) { - ranges.push(new vscode.Range(scopeRange.start.line, scopeRange.start.character, scopeRange.end.line, scopeRange.end.character)); - } - this.outsideRangeRunnableResults.push({ resultId: result.id, ranges }); - } - } - this.updateCacheState(result.state); - return result; - }; - - const results: ResolvedRunnableResult[] = []; - for (const runnableResult of body.runnableResults) { - const result = updateRunnableResult(runnableResult); - if (result !== undefined) { - results.push(result); - } - } - return { resolved: results, cached: cachedItems, referenced: referencedItems, serverComputed: serverComputed }; - } - - private updateCacheState(state: protocol.ContextRunnableState): void { - switch (this.cacheInfo.state) { - case CacheState.NotPopulated: - switch (state) { - case protocol.ContextRunnableState.Finished: - this.cacheInfo.state = CacheState.FullyPopulated; - break; - case protocol.ContextRunnableState.IsFull: - case protocol.ContextRunnableState.InProgress: - this.cacheInfo.state = CacheState.PartiallyPopulated; - break; - default: - this.cacheInfo.state = CacheState.NotPopulated; - } - break; - case CacheState.PartiallyPopulated: - // If the cache is partially populated we can only stay in that state. - break; - case CacheState.FullyPopulated: - switch (state) { - case protocol.ContextRunnableState.Finished: - // If the cache is fully populated we can only stay in that state. - break; - case protocol.ContextRunnableState.IsFull: - case protocol.ContextRunnableState.InProgress: - this.cacheInfo.state = CacheState.PartiallyPopulated; - break; - default: - this.cacheInfo.state = CacheState.NotPopulated; - } - break; - } - } - - public getRequestId(): string | undefined { - return this.requestInfo?.requestId; - } - - public getNodePath(): number[] { - return this.requestInfo?.path ?? [0]; - } - - public getRunnableResult(id: protocol.ContextRunnableResultId): ResolvedRunnableResult | undefined { - return this.results.get(id); - } - - public getCachedRunnableResults(document: vscode.TextDocument, position: vscode.Position, emitMode?: protocol.EmitMode): ResolvedRunnableResult[] { - const results: ResolvedRunnableResult[] = []; - if (this.requestInfo?.document !== document.uri.toString()) { - return results; - } - if (this.cacheInfo.version !== document.version || this.cacheInfo.state === CacheState.NotPopulated || this.requestInfo.path.length === 0 || this.requestInfo.path[0] === 0) { - return results; - } - for (const item of this.results.values()) { - if (emitMode !== undefined && item.cache?.emitMode === emitMode) { - continue; - } - const scope = item.cache?.scope; - if (scope === undefined || scope.kind !== protocol.CacheScopeKind.WithinRange) { - results.push(item); - } else { - const r = scope.range; - const range = new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character); - if (range.contains(position)) { - results.push(item); - } - } - } - // Sort them by priority so that the most important items are emitted first if they - // are contained in more than one runnable result. - return results.sort((a, b) => { - return a.priority < b.priority ? 1 : a.priority > b.priority ? -1 : 0; - }); - } - - public getContextRequestState(document: vscode.TextDocument, position: vscode.Position): ContextRequestState | undefined { - if (this.requestInfo?.document !== document.uri.toString()) { - return undefined; - } - if (this.cacheInfo.version !== document.version || this.cacheInfo.state === CacheState.NotPopulated || this.requestInfo.path.length === 0 || this.requestInfo.path[0] === 0) { - return undefined; - } - const items: Map = new Map(); - const client: ResolvedRunnableResult[] = []; - const clientOnTimeout: ResolvedRunnableResult[] = []; - const server: protocol.CachedContextRunnableResult[] = []; - if (this.isCacheFullyUpToDate(document, position)) { - for (const item of this.results.values()) { - client.push(item); - } - } else { - const canSkipItems = (rr: ResolvedRunnableResult, cache: protocol.CacheInfo): boolean => { - if (rr.state === protocol.ContextRunnableState.Finished) { - return true; - } - if (rr.state === protocol.ContextRunnableState.IsFull) { - const kind = cache.scope.kind; - return kind === protocol.CacheScopeKind.WithinRange || kind === protocol.CacheScopeKind.NeighborFiles || kind === protocol.CacheScopeKind.File; - } - return false; - }; - const handleRunnableResult = (id: string, rr: ResolvedRunnableResult) => { - const cache = rr.cache; - const cachedResult: protocol.CachedContextRunnableResult = { - id: id, - kind: protocol.ContextRunnableResultKind.CacheEntry, - state: rr.state, - items: [] - }; - let skipItems = false; - if (cache !== undefined) { - cachedResult.cache = cache; - const emitMode = cache.emitMode; - if (emitMode === protocol.EmitMode.ClientBased) { - client.push(rr); - skipItems = canSkipItems(rr, cache); - } else if (emitMode === protocol.EmitMode.ClientBasedOnTimeout) { - clientOnTimeout.push(rr); - } - } - server.push(cachedResult); - - if (skipItems) { - return; - } - - // Add cached context items to the result; - for (const item of rr.items) { - if (!protocol.ContextItem.hasKey(item)) { - continue; - } - const key = item.key; - let size: number | undefined = undefined; - switch (item.kind) { - case protocol.ContextKind.Snippet: - size = protocol.CodeSnippet.sizeInChars(item); - break; - case protocol.ContextKind.Trait: - size = protocol.Trait.sizeInChars(item); - break; - default: - } - cachedResult.items.push(protocol.CachedContextItem.create(key, size)); - items.set(key, item); - } - }; - // We don't need to sort by priority here since the data is used for the next cache request. - for (const [id, item] of this.results.entries()) { - const scope = item.cache?.scope; - if (scope === undefined || scope.kind !== protocol.CacheScopeKind.WithinRange) { - handleRunnableResult(id, item); - } else { - const r = scope.range; - const range = new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character); - if (range.contains(position)) { - handleRunnableResult(id, item); - } - } - } - } - return { client, clientOnTimeout, server, itemMap: items, resultMap: new Map(this.results) }; - } - - private isCacheFullyUpToDate(document: vscode.TextDocument, position: vscode.Position): boolean { - if (this.requestInfo === undefined) { - return false; - } - if (this.requestInfo.document !== document.uri.toString()) { - return false; - } - - // Same document, version and position. Cache can be full used. - if (this.requestInfo.version === document.version && this.requestInfo.position.isEqual(position)) { - return true; - } - - // Document is older than cached request. Not up to date. - if (this.requestInfo.version > document.version) { - return false; - } - - // if the position is not contained in all ranges return false. - for (const runnable of this.withInRangeRunnableResults) { - if (!runnable.range.contains(position)) { - return false; - } - } - - const range = position.isBefore(this.requestInfo.position) ? new vscode.Range(position, this.requestInfo.position) : new vscode.Range(this.requestInfo.position, position); - const text = document.getText(range); - return text.trim().length === 0; - } - - public dispose(): void { - this.clear(); - this.disposables.dispose(); - } - - private affectsTypeScript(event: vscode.TextDocumentChangeEvent): boolean { - const languageId = event.document.languageId; - return languageId === 'typescript' || languageId === 'typescriptreact' || languageId === 'javascript' || languageId === 'javascriptreact' || languageId === 'json'; - } - - private applyTextContentChangeEventToWithinRange(event: vscode.TextDocumentContentChangeEvent, range: vscode.Range): vscode.Range { - // The start stays untouched since the change range is contained in the range. - const eventRange = event.range; - const eventText = event.text; - - // Calculate how many lines the new text adds or removes - const linesDelta = (eventText.match(/\n/g) || []).length - (eventRange.end.line - eventRange.start.line); - - // Calculate the new end position - const endLine = range.end.line + linesDelta; - - let endCharacter = range.end.character; - if (eventRange.end.line === range.end.line) { - // Calculate the character delta for the last line of the change - const lastNewLineIndex = eventText.lastIndexOf('\n'); - const newTextLength = lastNewLineIndex !== -1 ? eventText.length - lastNewLineIndex - 1 : eventText.length; - const oldTextLength = eventRange.end.character - (eventRange.end.line > eventRange.start.line ? 0 : eventRange.start.character); - const charDelta = newTextLength - oldTextLength; - endCharacter += charDelta; - } - return new vscode.Range(range.start, new vscode.Position(endLine, endCharacter)); - } - - private applyTextContentChangeEventToOutsideRanges(event: vscode.TextDocumentContentChangeEvent, ranges: vscode.Range[]): vscode.Range[] | undefined { - if (ranges.length === 0) { - return ranges; - } - const changeRange = event.range; - const eventText = event.text; - - // Quick optimization: if change is completely after last range, no ranges need adjustment - const lastRange = ranges[ranges.length - 1]; - if (changeRange.start.isAfter(lastRange.end)) { - return ranges; - } - // Calculate how many lines the new text adds or removes - const linesDelta = (eventText.match(/\n/g) || []).length - (changeRange.end.line - changeRange.start.line); - const adjustedRanges: vscode.Range[] = []; - - for (const range of ranges) { - if (range.end.isBefore(changeRange.start)) { - // Range is completely before change, no adjustment needed - adjustedRanges.push(range); - } else if (range.start.isAfter(changeRange.end)) { - // Range is completely after change, adjust by lines delta - if (linesDelta === 0) { - adjustedRanges.push(range); - } else { - adjustedRanges.push(new vscode.Range( - new vscode.Position(range.start.line + linesDelta, range.start.character), - new vscode.Position(range.end.line + linesDelta, range.end.character) - )); - } - } else { - - // The range intersects with the range with will invalidate the cache entry. - return undefined; - } - } - - return adjustedRanges; - } -} - -namespace TextDocuments { - export function consider(document: vscode.TextDocument): boolean { - return document.uri.scheme === 'file' && (document.languageId === 'typescript' || document.languageId === 'typescriptreact'); - } -} - -class NeighborFileModel implements vscode.Disposable { - - private static readonly MAX_ITEMS = 12; - - private readonly disposables; - private readonly visible: LRUCache; - private readonly notVisible: LRUCache; - - constructor() { - this.disposables = new DisposableStore(); - this.visible = new LRUCache({ max: NeighborFileModel.MAX_ITEMS }); - this.notVisible = new LRUCache({ max: NeighborFileModel.MAX_ITEMS }); - this.disposables.add(vscode.window.onDidChangeActiveTextEditor((editor: vscode.TextEditor | undefined) => { - if (editor === undefined) { - return; - } - const document = editor.document; - if (TextDocuments.consider(document)) { - const uri = document.uri.toString(); - this.visible.set(uri, document.uri.fsPath); - this.notVisible.delete(uri); - } - })); - this.disposables.add(vscode.workspace.onDidCloseTextDocument((document: vscode.TextDocument) => { - const uri = document.uri.toString(); - if (TextDocuments.consider(document)) { - this.visible.delete(uri); - this.notVisible.delete(uri); - } - })); - this.disposables.add(vscode.window.tabGroups.onDidChangeTabs((e: vscode.TabChangeEvent) => { - // We don't track open tabs here to ensure we only track documents that are - // actually focused. Otherwise opening multiple tabs at once would cause too much churn. - for (const tab of e.closed) { - if (tab.input instanceof vscode.TabInputText) { - const uri = tab.input.uri.toString(); - const isVisible = this.visible.has(uri); - if (isVisible) { - this.visible.delete(uri); - this.notVisible.set(uri, tab.input.uri.fsPath); - } - } - } - })); - const textDocumentsToConsider: Map = new Map(); - for (const document of vscode.workspace.textDocuments) { - if (TextDocuments.consider(document)) { - textDocumentsToConsider.set(document.uri.toString(), document.uri); - } - } - for (const group of vscode.window.tabGroups.all) { - for (const tab of group.tabs) { - const uri = tab.input instanceof vscode.TabInputText ? tab.input.uri : undefined; - if (uri !== undefined && textDocumentsToConsider.has(uri.toString())) { - this.visible.set(uri.toString(), uri.fsPath); - textDocumentsToConsider.delete(uri.toString()); - } - } - } - for (const [key, uri] of textDocumentsToConsider.entries()) { - this.notVisible.set(key, uri.fsPath); - } - if (vscode.window.activeTextEditor !== undefined) { - const document = vscode.window.activeTextEditor.document; - if (TextDocuments.consider(document)) { - const uri = document.uri.toString(); - this.visible.set(uri, document.uri.fsPath); - this.notVisible.delete(uri); - } - } - } - - public getNeighborFiles(currentDocument: vscode.TextDocument): string[] { - const result: string[] = []; - const currentUri = currentDocument.uri.toString(); - for (const [key, value] of this.visible.entries()) { - if (key === currentUri) { - continue; - } - result.push(value); - } - if (result.length < NeighborFileModel.MAX_ITEMS) { - for (const [key, value] of this.notVisible.entries()) { - if (key === currentUri) { - continue; - } - result.push(value); - if (result.length >= NeighborFileModel.MAX_ITEMS) { - break; - } - } - } - return result; - } - - public dispose(): void { - this.disposables.dispose(); - } -} - -type ComputeContextRequestArgs = Omit & { - file: vscode.Uri; - line: number; - offset: number; - $traceId?: string; -}; -namespace ComputeContextRequestArgs { - export function create(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, startTime: number, timeBudget: number, willLogRequestTelemetry: boolean, neighborFiles: readonly string[] | undefined, clientSideRunnableResults: readonly protocol.CachedContextRunnableResult[] | undefined, includeDocumentation: boolean): ComputeContextRequestArgs { - return { - file: vscode.Uri.file(document.fileName), - line: position.line + 1, - offset: position.character + 1, - startTime: startTime, - timeBudget: timeBudget, - primaryCharacterBudget: (context.tokenBudget ?? 7 * 1024) * 4, - secondaryCharacterBudget: (currentTokenBudget * 4), - includeDocumentation: includeDocumentation, - neighborFiles: neighborFiles !== undefined && neighborFiles.length > 0 ? neighborFiles : undefined, - clientSideRunnableResults: clientSideRunnableResults, - $traceId: willLogRequestTelemetry ? context.requestId : undefined - }; - } -} - -class PendingRequestInfo { - - public readonly document: string; - public readonly version: number; - public readonly position: vscode.Position; - public readonly context: RequestContext; - - constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext) { - this.document = document.uri.toString(); - this.version = document.version; - this.position = position; - this.context = context; - } -} - -class InflightRequestInfo { - - public readonly document: string; - public readonly position: vscode.Position; - public readonly requestId: string; - public readonly source: KnownSources | string; - public readonly serverPromise: Thenable; - - private readonly tokenSource: vscode.CancellationTokenSource; - - constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, tokenSource: vscode.CancellationTokenSource, serverPromise: Thenable) { - this.document = document.uri.toString(); - this.position = position; - this.requestId = context.requestId; - this.source = context.source ?? KnownSources.unknown; - this.tokenSource = tokenSource; - this.serverPromise = serverPromise; - } - - public matches(document: vscode.TextDocument, position: vscode.Position): boolean { - return this.document === document.uri.toString() && this.position.isEqual(position); - } - - public matchesDocument(document: vscode.TextDocument): boolean { - return this.document === document.uri.toString(); - } - - public cancel(): void { - this.tokenSource.cancel(); - } -} - -class OnTimeoutData { - - private readonly document: string; - private readonly version: number; - private readonly position: vscode.Position; - - public readonly runnableResults: ResolvedRunnableResult[] = []; - public resultBuilder: ContextItemResultBuilder | undefined; - - constructor(document: vscode.TextDocument, position: vscode.Position) { - this.document = document.uri.toString(); - this.version = document.version; - this.position = position; - } - - addRunnableResult(result: ResolvedRunnableResult): void { - this.runnableResults.push(result); - } - - addRunnableResults(results: readonly ResolvedRunnableResult[]): void { - this.runnableResults.push(...results); - } - - matches(document: vscode.TextDocument, position: vscode.Position): boolean { - return this.document === document.uri.toString() && this.version === document.version && this.position.isEqual(position); - } -} - -enum ContextItemUsageMode { - minimal = 'minimal', - double = 'double', - fillHalf = 'fillHalf', - fill = 'fill' -} -namespace ContextItemUsageMode { - export function fromString(value: string): ContextItemUsageMode { - switch (value) { - case 'minimal': return ContextItemUsageMode.minimal; - case 'double': return ContextItemUsageMode.double; - case 'fillHalf': return ContextItemUsageMode.fillHalf; - case 'fill': return ContextItemUsageMode.fill; - default: return ContextItemUsageMode.minimal; - } - } -} - -class CharacterBudget { - - public readonly overall: number; - private mandatory: number; - private optional: number; - private start: { mandatory: number; optional: number }; - - constructor(mandatory: number, optional: number) { - this.overall = mandatory; - this.mandatory = mandatory; - this.optional = optional; - this.start = { mandatory, optional }; - } - - spend(chars: number): void { - this.mandatory -= chars; - this.optional -= chars; - } - - isExhausted(): boolean { - return this.mandatory <= 0; - } - - isOptionalExhausted(): boolean { - return this.optional <= 0; - } - - public fresh(): CharacterBudget { - return new CharacterBudget(this.start.mandatory, this.start.optional); - } -} +import { ContextItemSummary, ErrorLocation, ErrorPart, type OnCachePopulatedEvent, type OnContextComputedEvent, type OnContextComputedOnTimeoutEvent } from './types'; +import { TS6LanguageContextService } from './tsc6/tsContextService'; +import { TS7LanguageContextService } from './ts7/tsContextService'; +import { currentTokenBudget, NullTSLanguageContextService, type TSLanguageContextService } from './tsContextService'; +import { TypeScript } from './tsService'; +import { TelemetrySender } from './telemetrySender'; export class LanguageContextServiceImpl implements ILanguageContextService, vscode.Disposable { - private static readonly defaultCachePopulationBudget: number = 500; - private static readonly defaultCachePopulationRaceTimeout: number = 20; - private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic }; - readonly _serviceBrand: undefined; private readonly disposables: DisposableStore; + private readonly serviceListeners: DisposableStore; - private readonly isDebugging: boolean; - private _isActivated: Promise | undefined; - private telemetrySender: TelemetrySender; - - private readonly runnableResultManager: RunnableResultManager; - private readonly neighborFileModel: NeighborFileModel; - - private pendingRequest: PendingRequestInfo | undefined; - private inflightCachePopulationRequest: InflightRequestInfo | undefined; - private onTimeoutData: OnTimeoutData | undefined; - private cachePopulationTimeout: number; - private usageMode: ContextItemUsageMode; - private includeDocumentation: boolean; + private readonly _onCachePopulated: vscode.EventEmitter; + private readonly _onContextComputed: vscode.EventEmitter; + private readonly _onContextComputedOnTimeout: vscode.EventEmitter; - private _onCachePopulated: vscode.EventEmitter; - public readonly onCachePopulated: vscode.Event; - - private _onContextComputed: vscode.EventEmitter; - public readonly onContextComputed: vscode.Event; - - private _onContextComputedOnTimeout: vscode.EventEmitter; - public readonly onContextComputedOnTimeout: vscode.Event; + private tsLanguageContextService: TSLanguageContextService; constructor( - @ITelemetryService telemetryService: ITelemetryService, + @ITelemetryService private readonly telemetryService: ITelemetryService, @IConfigurationService private readonly configurationService: IConfigurationService, @IExperimentationService private readonly experimentationService: IExperimentationService, @ILogService private readonly logService: ILogService ) { - this.isDebugging = process.execArgv.some((arg) => /^--(?:inspect|debug)(?:-brk)?(?:=\d+)?$/i.test(arg)); - this.telemetrySender = new TelemetrySender(telemetryService, logService); - this.runnableResultManager = new RunnableResultManager(); - this.neighborFileModel = new NeighborFileModel(); - this.pendingRequest = undefined; - this.inflightCachePopulationRequest = undefined; - this.onTimeoutData = undefined; - this.cachePopulationTimeout = this.getCachePopulationBudget(); - this.usageMode = this.getUsageMode(); - this.includeDocumentation = this.getIncludeDocumentation(); - this.disposables = new DisposableStore(); - this.disposables.add(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextMode.fullyQualifiedId)) { - this.usageMode = this.getUsageMode(); - } else if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextCacheTimeout.fullyQualifiedId)) { - this.cachePopulationTimeout = this.getCachePopulationBudget(); - } else if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextIncludeDocumentation.fullyQualifiedId)) { - this.includeDocumentation = this.getIncludeDocumentation(); - } - })); - + this.serviceListeners = this.disposables.add(new DisposableStore()); this._onCachePopulated = this.disposables.add(new vscode.EventEmitter()); - this.onCachePopulated = this._onCachePopulated.event; - this._onContextComputed = this.disposables.add(new vscode.EventEmitter()); - this.onContextComputed = this._onContextComputed.event; - this._onContextComputedOnTimeout = this.disposables.add(new vscode.EventEmitter()); - this.onContextComputedOnTimeout = this._onContextComputedOnTimeout.event; + const runsTS7 = TypeScript.runsVersion7(); + const enableTS7 = TypeScript.isVersion7SupportEnabled(this.configurationService); + this.tsLanguageContextService = runsTS7 + ? enableTS7 + ? new TS7LanguageContextService(this.telemetryService, this.configurationService, this.experimentationService, this.logService) + : new NullTSLanguageContextService() + : new TS6LanguageContextService(this.telemetryService, this.configurationService, this.experimentationService, this.logService); + this.bindEvents(); + this.disposables.add(this.configurationService.onDidChangeConfiguration((e) => { + if (TypeScript.affectsVersion(e) || e.affectsConfiguration(ConfigKey.TypeScript7LanguageContext.fullyQualifiedId)) { + this.updateTSLanguageContextService(); + } + })); } public dispose(): void { - this.runnableResultManager.dispose(); - this.neighborFileModel.dispose(); - this.inflightCachePopulationRequest = undefined; + this.tsLanguageContextService.dispose(); + this.disposables.dispose(); } - async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { - const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; - if (languageId !== 'typescript' && languageId !== 'typescriptreact') { - return false; - } - if (this._isActivated === undefined) { - this._isActivated = this.doIsTypeScriptActivated(languageId); - } - return this._isActivated; + public get onCachePopulated() { + return this._onCachePopulated.event; } - private async doIsTypeScriptActivated(languageId: string): Promise { - - let activated = false; - - try { - // Check that the TypeScript extension is installed and runs in the same extension host. - const typeScriptExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); - if (typeScriptExtension === undefined) { - return false; - } - - // Make sure the TypeScript extension is activated. - await typeScriptExtension.activate(); + public get onContextComputed() { + return this._onContextComputed.event; + } - // Send a ping request to see if the TS server plugin got installed correctly. - const response: protocol.PingResponse | undefined = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.ping', LanguageContextServiceImpl.ExecConfig, CancellationToken.None); - this.telemetrySender.sendActivationTelemetry(response, undefined); - if (response !== undefined) { - if (response.body?.kind === 'ok') { - this.logService.info('TypeScript server plugin activated.'); - activated = true; - } else { - this.logService.error('TypeScript server plugin not activated:', response.body?.message ?? 'Message not provided.'); - } - } else { - this.logService.error('TypeScript server plugin not activated:', 'No ping response received.'); - } - } catch (error) { - this.telemetrySender.sendActivationTelemetry(undefined, error); - this.logService.error('Error pinging TypeScript server plugin:', error); - } + public get onContextComputedOnTimeout() { + return this._onContextComputedOnTimeout.event; + } - return activated; + async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + return this.tsLanguageContextService.isActivated(documentOrLanguageId); } async populateCache(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): Promise { - if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { - return; - } - if (this.inflightCachePopulationRequest !== undefined) { - if (!this.inflightCachePopulationRequest.matches(document, position)) { - // We have a request running. Do not issue another cache request but remember the pending request. - this.pendingRequest = new PendingRequestInfo(document, position, context); - } - return; - } - const startTime = Date.now(); - const contextRequestState = this.runnableResultManager.getContextRequestState(document, position); - if (contextRequestState !== undefined && contextRequestState.server.length === 0) { - // There is nothing to do on the server. Cache is up to date. - return; - } - const neighborFiles: string[] = this.neighborFileModel.getNeighborFiles(document); - const timeBudget = this.cachePopulationTimeout; - const willLogRequestTelemetry = this.telemetrySender.willLogRequestTelemetry(context); - const args: ComputeContextRequestArgs = ComputeContextRequestArgs.create( - document, position, context, startTime, timeBudget, willLogRequestTelemetry, - neighborFiles, contextRequestState?.server, this.includeDocumentation - ); - try { - const isDebugging = this.isDebugging; - const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; - const tokenSource = new vscode.CancellationTokenSource(); - const token = tokenSource.token; - const documentVersion = document.version; - const cacheState = this.runnableResultManager.getCacheState(); - let response: protocol.ComputeContextResponse; - let inflightRequest: InflightRequestInfo | undefined = undefined; - try { - const promise: Thenable = vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.context', args, LanguageContextServiceImpl.ExecConfig, token); - inflightRequest = new InflightRequestInfo(document, position, context, tokenSource, promise); - this.inflightCachePopulationRequest = inflightRequest; - response = await promise; - } finally { - if (this.inflightCachePopulationRequest === inflightRequest) { - this.inflightCachePopulationRequest = undefined; - } - tokenSource.dispose(); - } - const timeTaken = Date.now() - startTime; - if (protocol.ComputeContextResponse.isCancelled(response)) { - this.telemetrySender.sendRequestCancelledTelemetry(context, timeTaken); - } else if (protocol.ComputeContextResponse.isOk(response)) { - const body: protocol.ComputeContextResponse.OK = response.body; - const contextItemResult = new ContextItemResultBuilder(timeTaken); - const { resolved, cached, referenced, serverComputed } = this.runnableResultManager.update(document, documentVersion, position, context, body, contextRequestState); - contextItemResult.cachedItems += cached; - contextItemResult.referencedItems += referenced; - contextItemResult.serverComputed = serverComputed; - if (resolved.length > 0) { - // Update the stats for telemetry. - for (const runnableResult of resolved) { - for (const converted of contextItemResult.update(runnableResult)) { - forDebugging?.push(converted.item); - } - } - } - contextItemResult.updateResponse(body, token); - this.telemetrySender.sendRequestTelemetry(document, position, context, contextItemResult, timeTaken, { before: cacheState, after: this.runnableResultManager.getCacheState() }, undefined); - // eslint-disable-next-line local/code-no-unused-expressions - isDebugging && forDebugging?.length; - this._onCachePopulated.fire({ document, position, source: context.source, items: resolved, summary: contextItemResult }); - } else if (protocol.ComputeContextResponse.isError(response)) { - this.telemetrySender.sendRequestFailureTelemetry(context, response.body); - console.error('Error populating cache:', response.body.message, response.body.stack); - } - } catch (error) { - this.logService.error(error, `Error populating cache for document: ${document.uri.toString()} at position: ${position.line + 1}:${position.character + 1}`); - } - if (this.pendingRequest !== undefined) { - // We had a pending request. Clear it and try to populate the cache again. - const pendingRequest = this.pendingRequest; - this.pendingRequest = undefined; - const textEditor = vscode.window.activeTextEditor; - if (textEditor !== undefined) { - const document = textEditor.document; - if (document.uri.toString() === pendingRequest.document && document.version === pendingRequest.version && document.validatePosition(pendingRequest.position).isEqual(pendingRequest.position)) { - this.populateCache(document, pendingRequest.position, pendingRequest.context).catch(() => { /* handled in populateCache */ }); - } - } - } + return this.tsLanguageContextService.populateCache(document, position, context); } public async *getContext(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, token: vscode.CancellationToken): AsyncIterable { - this.onTimeoutData = undefined; - if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { - return; - } - - const startTime = Date.now(); - let cacheRequest = 'none'; - const cachePopulationRequestInflight = this.inflightCachePopulationRequest !== undefined && this.inflightCachePopulationRequest.matchesDocument(document); - if (cachePopulationRequestInflight) { - this.onTimeoutData = new OnTimeoutData(document, position); - cacheRequest = 'inflight'; - } - if (token.isCancellationRequested) { - this.telemetrySender.sendRequestCancelledTelemetry(context, Date.now() - startTime); - return; - } - const isDebugging = this.isDebugging; - const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; - const contextItemResult = new ContextItemResultBuilder(Date.now() - startTime); - if (this.onTimeoutData !== undefined) { - this.onTimeoutData.resultBuilder = contextItemResult; - } - const characterBudget = this.getCharacterBudget(context, document); - // We first collect all items to yield so that the state of the cache doesn't change underneath us. - // This could otherwise happen if the cache population request finishes while we are yielding items. - const itemsToYield: ContextItem[] = []; - const { mandatory, optional, onTimeout } = this.getRunnables(document, position, cachePopulationRequestInflight); - if (this.onTimeoutData !== undefined) { - this.onTimeoutData.addRunnableResults(onTimeout); - } - outer: for (const runnableResult of mandatory) { - for (const { item, size } of contextItemResult.update(runnableResult, true)) { - forDebugging?.push(item); - characterBudget.spend(size); - if (characterBudget.isExhausted()) { - break outer; - } - itemsToYield.push(item); - } - } - if (!characterBudget.isOptionalExhausted()) { - outer: for (const runnableResult of optional) { - for (const { item, size } of contextItemResult.update(runnableResult, true)) { - forDebugging?.push(item); - characterBudget.spend(size); - if (characterBudget.isOptionalExhausted()) { - break outer; - } - itemsToYield.push(item); - } - } - } - if (!token.isCancellationRequested) { - for (const item of itemsToYield) { - if (token.isCancellationRequested) { - this.onTimeoutData = undefined; - break; - } - yield item; - } - - // Recheck for an inflight request and join it if it is for the same document and position. - if (this.inflightCachePopulationRequest !== undefined && this.inflightCachePopulationRequest.matchesDocument(document)) { - cacheRequest = 'inflight'; - // We have an inflight request for the same document and position. - // We wait for the server promise to resolve and then see if we can yield items from the - // inflight request. - const timeOut = Math.max(0, Math.min(context.timeBudget ?? LanguageContextServiceImpl.defaultCachePopulationRaceTimeout, LanguageContextServiceImpl.defaultCachePopulationRaceTimeout)); - const result = await Promise.race([this.inflightCachePopulationRequest.serverPromise, new Promise((resolve) => setTimeout(resolve, timeOut)).then(() => 'timedOut')]); - // The server promised resolved first. So the inflight request is done. - if (result !== 'timedOut') { - this.inflightCachePopulationRequest = undefined; - if (this.onTimeoutData !== undefined) { - this.onTimeoutData = undefined; - const runnableResults = this.runnableResultManager.getCachedRunnableResults(document, position, protocol.EmitMode.ClientBasedOnTimeout); - for (const runnableResult of runnableResults) { - for (const { item } of contextItemResult.update(runnableResult)) { - forDebugging?.push(item); - yield item; - } - } - cacheRequest = 'awaited'; - } - } - } - } else { - this.onTimeoutData = undefined; - } - - const isSpeculativeRequest = context.proposedEdits !== undefined; - if (isSpeculativeRequest) { - this.telemetrySender.sendSpeculativeRequestTelemetry(context, this.runnableResultManager.getRequestId() ?? 'unknown', contextItemResult.stats.yielded); - } else { - const cacheState = this.runnableResultManager.getCacheState(); - contextItemResult.path = this.runnableResultManager.getNodePath(); - contextItemResult.cancelled = token.isCancellationRequested; - contextItemResult.serverTime = 0; - contextItemResult.contextComputeTime = 0; - contextItemResult.fromCache = true; - this.telemetrySender.sendRequestTelemetry( - document, position, context, contextItemResult, Date.now() - startTime, - { before: cacheState, after: cacheState }, cacheRequest - ); - // eslint-disable-next-line local/code-no-unused-expressions - isDebugging && forDebugging?.length; - this._onContextComputed.fire({ - document, position, source: context.source, items: itemsToYield, summary: contextItemResult - }); - } - return; + yield* this.tsLanguageContextService.getContext(document, position, context, token); } - private getRunnables(document: vscode.TextDocument, position: vscode.Position, cachePopulationInflight: boolean): { mandatory: readonly ResolvedRunnableResult[]; optional: readonly ResolvedRunnableResult[]; onTimeout: readonly ResolvedRunnableResult[] } { - const mandatory: ResolvedRunnableResult[] = []; - const optional: ResolvedRunnableResult[] = []; - const onTimeout: ResolvedRunnableResult[] = []; - for (const runnable of this.runnableResultManager.getCachedRunnableResults(document, position)) { - if (cachePopulationInflight && runnable.cache?.emitMode === protocol.EmitMode.ClientBasedOnTimeout) { - onTimeout.push(runnable); - } else { - const priority = runnable.priority; - if (priority === protocol.Priorities.Expression || priority === protocol.Priorities.Locals || priority === protocol.Priorities.Inherited || priority === protocol.Priorities.Traits) { - mandatory.push(runnable); - } else { - optional.push(runnable); - } - } - } - return { mandatory, optional, onTimeout }; + public getContextOnTimeout(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): readonly ContextItem[] | undefined { + return this.tsLanguageContextService.getContextOnTimeout(document, position, context); } - public getContextOnTimeout(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): readonly ContextItem[] | undefined { - try { - if (this.onTimeoutData === undefined) { - return []; + private updateTSLanguageContextService(): void { + const runsTS7 = TypeScript.runsVersion7(); + const enableTS7 = TypeScript.isVersion7SupportEnabled(this.configurationService); + const oldService: TSLanguageContextService = this.tsLanguageContextService; + if (runsTS7) { + if (oldService instanceof TS6LanguageContextService) { + oldService.dispose(); + this.tsLanguageContextService = enableTS7 + ? new TS7LanguageContextService(this.telemetryService, this.configurationService, this.experimentationService, this.logService) + : new NullTSLanguageContextService(); + } else if (oldService instanceof TS7LanguageContextService && !enableTS7) { + oldService.dispose(); + this.tsLanguageContextService = new NullTSLanguageContextService(); + } else if (oldService instanceof NullTSLanguageContextService && enableTS7) { + oldService.dispose(); + this.tsLanguageContextService = new TS7LanguageContextService(this.telemetryService, this.configurationService, this.experimentationService, this.logService); } - if (!this.onTimeoutData.matches(document, position) || this.onTimeoutData.resultBuilder === undefined) { - return []; - } - const result: ContextItem[] = []; - const contextItemResult = this.onTimeoutData.resultBuilder; - for (const runnableResult of this.onTimeoutData.runnableResults) { - for (const { item } of contextItemResult.update(runnableResult, true)) { - result.push(item); - } - } - return result; - } finally { - this.onTimeoutData = undefined; + } else if (!runsTS7 && (oldService instanceof TS7LanguageContextService || oldService instanceof NullTSLanguageContextService)) { + oldService.dispose(); + this.tsLanguageContextService = new TS6LanguageContextService(this.telemetryService, this.configurationService, this.experimentationService, this.logService); + } + if (oldService !== this.tsLanguageContextService) { + this.bindEvents(); } } - private getCachePopulationBudget(): number { - const result = this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextCacheTimeout, this.experimentationService); - return result ?? LanguageContextServiceImpl.defaultCachePopulationBudget; - } - - private getUsageMode(): ContextItemUsageMode { - const value = this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextMode, this.experimentationService); - return ContextItemUsageMode.fromString(value); - } - - private getIncludeDocumentation(): boolean { - return this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextIncludeDocumentation, this.experimentationService); + private bindEvents(): void { + this.serviceListeners.clear(); + this.serviceListeners.add(this.tsLanguageContextService.onCachePopulated(e => this._onCachePopulated.fire(e))); + this.serviceListeners.add(this.tsLanguageContextService.onContextComputed(e => this._onContextComputed.fire(e))); + this.serviceListeners.add(this.tsLanguageContextService.onContextComputedOnTimeout(e => this._onContextComputedOnTimeout.fire(e))); } - private getCharacterBudget(context: RequestContext, document: vscode.TextDocument): CharacterBudget { - const chars = (context.tokenBudget ?? currentTokenBudget) * 4; - switch (this.usageMode) { - case ContextItemUsageMode.minimal: - return new CharacterBudget(chars, 0); - case ContextItemUsageMode.double: - return new CharacterBudget(chars, Math.min(chars, document.getText().length)); - case ContextItemUsageMode.fillHalf: - return new CharacterBudget(chars, Math.floor(chars / 2)); - case ContextItemUsageMode.fill: - return new CharacterBudget(chars, chars); - default: - return new CharacterBudget(chars, chars); - } - } } interface TokenBudgetProvider { @@ -1844,7 +370,7 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud private typeScriptFileOpen(): void { this.checkRegistration(); this.disposables.add(this.configurationService.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContext.fullyQualifiedId)) { + if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContext.fullyQualifiedId) || e.affectsConfiguration(ConfigKey.TypeScript7LanguageContext.fullyQualifiedId) || TypeScript.affectsVersion(e)) { this.checkRegistration(); } })); @@ -1863,6 +389,7 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud private async register(): Promise { if (! await this.isTypeScriptRunning()) { + this.unregister(); return; } @@ -1870,6 +397,7 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud const logService = this.logService; try { if (! await languageContextService.isActivated('typescript')) { + this.unregister(); return; } @@ -1987,9 +515,12 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud private async isTypeScriptRunning(): Promise { // Check that the TypeScript extension is installed and runs in the same extension host. - const typeScriptExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); + const useTypeScript7 = TypeScript.runsVersion7(); + const typeScriptExtension = useTypeScript7 + ? TypeScript.getVersion7Extension() + : vscode.extensions.getExtension('vscode.typescript-language-features'); if (typeScriptExtension === undefined) { - this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, 'TypeScript extension not found', undefined); + this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, 'TypeScript extension not found', useTypeScript7 ? 'ts6' : 'ts7'); this.logService.error('TypeScript extension not found'); return false; } @@ -1998,10 +529,10 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud return true; } catch (error) { if (error instanceof Error) { - this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, error.message, error.stack); + this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, error.message, error.stack, useTypeScript7 ? 'ts6' : 'ts7'); this.logService.error('Error checking if TypeScript plugin is installed:', error.message); } else { - this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, 'Unknown error', undefined); + this.telemetrySender.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.TypescriptPlugin, 'Unknown error', undefined, useTypeScript7 ? 'ts6' : 'ts7'); this.logService.error('Error checking if TypeScript plugin is installed: Unknown error'); } return false; diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts index 6201e9bfacc0cc..95d6a71ea163da 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts @@ -3,78 +3,45 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ILogService } from '../../../platform/log/common/logService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; -import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { DisposableStore } from '../../../util/vs/base/common/lifecycle'; import * as protocol from '../common/serverProtocol'; +import { TS7NesRenameService } from './ts7/nesRenameService'; +import { TS6NesRenameService } from './tsc6/nesRenameService'; +import { TypeScript } from './tsService'; -enum ExecutionTarget { - Semantic, - Syntax -} - -type ExecConfig = { - readonly lowPriority?: boolean; - readonly nonRecoverable?: boolean; - readonly cancelOnResourceChange?: vscode.Uri; - readonly executionTarget?: ExecutionTarget; +type TextChange = { + range: protocol.Range; + newText?: string; }; - -type PrepareNesRenameRequestArgs = Omit & { +type RenameGroup = { file: vscode.Uri; - line: number; - offset: number; + changes: TextChange[]; }; -namespace PrepareNesRenameRequestArgs { - export function create(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, startTime: number, timeBudget: number): PrepareNesRenameRequestArgs { - return { - file: vscode.Uri.file(document.fileName), - line: position.line + 1, - offset: position.character + 1, - oldName: oldName, - newName: newName, - lastSymbolRename: lastSymbolRename ? { - start: { line: lastSymbolRename.start.line + 1, character: lastSymbolRename.start.character + 1 }, - end: { line: lastSymbolRename.end.line + 1, character: lastSymbolRename.end.character + 1 } - } : undefined, - startTime: startTime, - timeBudget: timeBudget - }; - } +interface NesRenameService extends vscode.Disposable { + isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise; + prepare(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, startTime: number, timeBudget: number, token: vscode.CancellationToken): Promise; + postRename(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, token: vscode.CancellationToken): Promise; } -type NesRenameRequestArgs = Omit & { - file: vscode.Uri; - line: number; - offset: number; -}; +class NullNesRenameService implements NesRenameService { + public dispose(): void { } -namespace NesRenameRequestArgs { - export function create(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined): NesRenameRequestArgs { - return { - file: vscode.Uri.file(document.fileName), - line: position.line + 1, - offset: position.character + 1, - oldName: oldName, - newName: newName, - lastSymbolRename: lastSymbolRename ? { - start: { line: lastSymbolRename.start.line + 1, character: lastSymbolRename.start.character + 1 }, - end: { line: lastSymbolRename.end.line + 1, character: lastSymbolRename.end.character + 1 } - } : undefined - }; + public isActivated(): Promise { + return Promise.resolve(false); } -} -type TextChange = { - range: protocol.Range; - newText?: string; -}; -type RenameGroup = { - file: vscode.Uri; - changes: TextChange[]; -}; + public prepare(): Promise { + return Promise.resolve({ canRename: protocol.RenameKind.no, timedOut: false }); + } + + public postRename(): Promise { + return Promise.resolve([]); + } +} class TelemetrySender { @@ -137,18 +104,23 @@ class TelemetrySender { export class NesRenameContribution implements vscode.Disposable { - private _isActivated: Promise | undefined; private readonly disposables: DisposableStore; private readonly telemetrySender: TelemetrySender; - - private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic }; + private nesRenameService: NesRenameService; constructor( @ITelemetryService telemetryService: ITelemetryService, + @IConfigurationService private readonly configurationService: IConfigurationService, @ILogService private readonly logService: ILogService, ) { this.telemetrySender = new TelemetrySender(telemetryService, logService); this.disposables = new DisposableStore(); + this.nesRenameService = this.createNesRenameService(); + this.disposables.add(this.configurationService.onDidChangeConfiguration(e => { + if (TypeScript.affectsVersion(e) || e.affectsConfiguration(ConfigKey.TypeScript7LanguageContext.fullyQualifiedId)) { + this.updateNesRenameService(); + } + })); this.disposables.add(vscode.commands.registerCommand('github.copilot.nes.prepareRename', async (uri: vscode.Uri | undefined, position: vscode.Position | undefined, oldName: string | undefined, newName: string | undefined, requestId: string | undefined, lastSymbolRename: vscode.Range | undefined): Promise => { const no: protocol.PrepareNesRenameResult.No = { canRename: protocol.RenameKind.no, timedOut: false }; const params = this.resolvePrepareParams(uri, position, oldName, newName, requestId); @@ -160,31 +132,7 @@ export class NesRenameContribution implements vscode.Disposable { oldName = params.oldName; newName = params.newName; requestId = params.requestId; - - const activated = await this.isActivated(document); - if (!activated) { - return no; - } - - const startTime = Date.now(); - const args: PrepareNesRenameRequestArgs = PrepareNesRenameRequestArgs.create(document, position, oldName, newName, lastSymbolRename, startTime, 300); - - const tokenSource = new vscode.CancellationTokenSource(); - try { - const result = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.prepareNesRename', args, NesRenameContribution.ExecConfig, tokenSource.token); - if (protocol.PrepareNesRenameResponse.isError(result)) { - this.telemetrySender.sendPrepareNesRenameFailureTelemetry(requestId, result.body); - return no; - } else if (protocol.PrepareNesRenameResponse.isOk(result)) { - const timedOut = result.body.canRename === protocol.RenameKind.no ? result.body.timedOut : false; - this.telemetrySender.sendPrepareNesRenameTelemetry(requestId, Date.now() - startTime, result.body.canRename, timedOut); - return result.body; - } else { - return no; - } - } finally { - tokenSource.dispose(); - } + return this.prepareRename(document, position, oldName, newName, requestId, lastSymbolRename); })); this.disposables.add(vscode.commands.registerCommand('github.copilot.nes.postRename', async (uri: vscode.Uri | undefined, position: vscode.Position | undefined, oldName: string | undefined, newName: string | undefined, lastSymbolRename: vscode.Range | undefined): Promise => { const params = this.resolveRenameParams(uri, position, oldName, newName); @@ -195,23 +143,7 @@ export class NesRenameContribution implements vscode.Disposable { position = params.position; oldName = params.oldName; newName = params.newName; - const args: NesRenameRequestArgs = NesRenameRequestArgs.create(document, position, oldName, newName, lastSymbolRename); - const tokenSource = new vscode.CancellationTokenSource(); - try { - const result = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.postNesRename', args, NesRenameContribution.ExecConfig, tokenSource.token); - if (protocol.NesRenameResponse.isError(result)) { - return []; - } else if (protocol.NesRenameResponse.isOk(result)) { - return result.body.groups.map(group => ({ - changes: group.changes, - file: vscode.Uri.file(group.file) - })); - } else { - return []; - } - } finally { - tokenSource.dispose(); - } + return this.postRename(document, position, oldName, newName, lastSymbolRename); })); this.disposables.add(vscode.commands.registerCommand('github.copilot.debug.validateNesRename', async () => { const params = await this.getUserParams(); @@ -225,73 +157,102 @@ export class NesRenameContribution implements vscode.Disposable { return; } - const args: PrepareNesRenameRequestArgs = PrepareNesRenameRequestArgs.create(document, position, oldName, newName, new vscode.Range(1, 7, 1, 13), Date.now(), 300); - const tokenSource = new vscode.CancellationTokenSource(); - try { - const result = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.prepareNesRename', args, NesRenameContribution.ExecConfig, tokenSource.token); - if (protocol.PrepareNesRenameResponse.isError(result)) { - vscode.window.showErrorMessage(`Prepare NES Rename error: ${result.message}`); - } else if (protocol.PrepareNesRenameResponse.isOk(result)) { - const body = result.body; - if (body.canRename === protocol.RenameKind.yes) { - vscode.window.showInformationMessage(`Prepare NES Rename: Can rename '${oldName}' to '${newName}'.`); - } else if (body.canRename === protocol.RenameKind.maybe) { - vscode.window.showWarningMessage(`Prepare NES Rename: Maybe can rename '${oldName}' to '${newName}'.`); - } else { - vscode.window.showErrorMessage(`Prepare NES Rename: Cannot rename '${oldName}' to '${newName}'. Reason: ${body.reason ?? 'Not provided'}`); - } - } - } finally { - tokenSource.dispose(); + const result = await this.prepareRename(document, position, oldName, newName, 'debug', new vscode.Range(1, 7, 1, 13)); + if (result.canRename === protocol.RenameKind.yes) { + vscode.window.showInformationMessage(`Prepare NES Rename: Can rename '${oldName}' to '${newName}'.`); + } else if (result.canRename === protocol.RenameKind.maybe) { + vscode.window.showWarningMessage(`Prepare NES Rename: Maybe can rename '${oldName}' to '${newName}'.`); + } else { + vscode.window.showErrorMessage(`Prepare NES Rename: Cannot rename '${oldName}' to '${newName}'. Reason: ${result.reason ?? 'Not provided'}`); } })); } public dispose(): void { + this.nesRenameService.dispose(); this.disposables.dispose(); } - private async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { - const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; - if (languageId !== 'typescript' && languageId !== 'typescriptreact') { - return false; + private async prepareRename(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, requestId: string, lastSymbolRename: vscode.Range | undefined): Promise { + const no: protocol.PrepareNesRenameResult.No = { canRename: protocol.RenameKind.no, timedOut: false }; + const service = this.nesRenameService; + if (!await service.isActivated(document)) { + return no; } - if (this._isActivated === undefined) { - this._isActivated = this.doIsTypeScriptActivated(languageId); + + const startTime = Date.now(); + const timeBudget = 300; + const tokenSource = new vscode.CancellationTokenSource(); + try { + const body = await service.prepare(document, position, oldName, newName, lastSymbolRename, startTime, timeBudget, tokenSource.token); + if ('error' in body) { + this.telemetrySender.sendPrepareNesRenameFailureTelemetry(requestId, body); + return no; + } + const timedOut = body.canRename === protocol.RenameKind.no ? body.timedOut : false; + this.telemetrySender.sendPrepareNesRenameTelemetry(requestId, Date.now() - startTime, body.canRename, timedOut); + return body; + } catch (error) { + const data: protocol.CustomResponse.Failed = error instanceof Error + ? { error: protocol.ErrorCode.exception, message: error.message, stack: error.stack } + : { error: protocol.ErrorCode.exception, message: 'Unknown error' }; + this.telemetrySender.sendPrepareNesRenameFailureTelemetry(requestId, data); + this.logService.error(`Error preparing TypeScript ${TypeScript.runsVersion7() ? '7' : '6'} NES rename:`, error); + return no; + } finally { + tokenSource.dispose(); } - return this._isActivated; } - private async doIsTypeScriptActivated(languageId: string): Promise { - let activated = false; - + private async postRename(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined): Promise { + const tokenSource = new vscode.CancellationTokenSource(); try { - // Check that the TypeScript extension is installed and runs in the same extension host. - const typeScriptExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); - if (typeScriptExtension === undefined) { - return false; - } + const groups = await this.nesRenameService.postRename(document, position, oldName, newName, lastSymbolRename, tokenSource.token); + return groups.map(group => ({ + changes: group.changes, + file: vscode.Uri.file(group.file), + })); + } catch (error) { + this.logService.error(`Error computing TypeScript ${TypeScript.runsVersion7() ? '7' : '6'} NES rename edits:`, error); + return []; + } finally { + tokenSource.dispose(); + } + } - // Make sure the TypeScript extension is activated. - await typeScriptExtension.activate(); + private async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + return this.nesRenameService.isActivated(documentOrLanguageId); + } - // Send a ping request to see if the TS server plugin got installed correctly. - const response: protocol.PingResponse | undefined = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.ping', NesRenameContribution.ExecConfig, CancellationToken.None); - if (response !== undefined) { - if (response.body?.kind === 'ok') { - this.logService.info('TypeScript server plugin activated.'); - activated = true; - } else { - this.logService.error('TypeScript server plugin not activated:', response.body?.message ?? 'Message not provided.'); - } - } else { - this.logService.error('TypeScript server plugin not activated:', 'No ping response received.'); + private updateNesRenameService(): void { + const runsTS7 = TypeScript.runsVersion7(); + const enableTS7 = TypeScript.isVersion7SupportEnabled(this.configurationService); + const oldService = this.nesRenameService; + if (runsTS7) { + if (oldService instanceof TS6NesRenameService) { + this.nesRenameService = enableTS7 + ? new TS7NesRenameService(this.logService) + : new NullNesRenameService(); + } else if (oldService instanceof TS7NesRenameService && !enableTS7) { + this.nesRenameService = new NullNesRenameService(); + } else if (oldService instanceof NullNesRenameService && enableTS7) { + this.nesRenameService = new TS7NesRenameService(this.logService); } - } catch (error) { - this.logService.error('Error pinging TypeScript server plugin:', error); + } else if (!(oldService instanceof TS6NesRenameService)) { + this.nesRenameService = new TS6NesRenameService(this.logService); } + if (oldService !== this.nesRenameService) { + oldService.dispose(); + } + } - return activated; + private createNesRenameService(): NesRenameService { + if (!TypeScript.runsVersion7()) { + return new TS6NesRenameService(this.logService); + } + return TypeScript.isVersion7SupportEnabled(this.configurationService) + ? new TS7NesRenameService(this.logService) + : new NullNesRenameService(); } private resolvePrepareParams(uri: vscode.Uri | undefined, position: vscode.Position | undefined, oldName: string | undefined, newName: string | undefined, requestId: string | undefined): { document: vscode.TextDocument; position: vscode.Position; oldName: string; newName: string; requestId: string } | undefined { diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/telemetrySender.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/telemetrySender.ts new file mode 100644 index 00000000000000..9bec0708232ebe --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/telemetrySender.ts @@ -0,0 +1,423 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +import { ILogService } from '../../../platform/log/common/logService'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; +import { KnownSources, TriggerKind, type RequestContext } from '../../../platform/languageServer/common/languageContextService'; +import { ContextItemSummary, ErrorLocation, ErrorPart, type CacheState } from './types'; + +import * as protocol from '../common/serverProtocol'; + +namespace RequestContext { + export function getSampleTelemetry(context: RequestContext): number { + return Math.max(1, Math.min(100, context.sampleTelemetry ?? 1)); + } +} + +interface TypeScriptServerError extends Error { + response: { + type: 'response'; + command: string; + message: string; + }; + version: { + displayName: string; + }; +} +namespace TypeScriptServerError { + export function is(value: Error): value is TypeScriptServerError { + const candidate = value as TypeScriptServerError; + return candidate instanceof Error && candidate.response !== undefined && candidate.version !== undefined && typeof candidate.version.displayName === 'string'; + } +} + +export class TelemetrySender { + + private readonly telemetryService: ITelemetryService; + private readonly logService: ILogService; + private sendRequestTelemetryCounter: number; + private sendSpeculativeRequestTelemetryCounter: number; + + constructor(telemetryService: ITelemetryService, logService: ILogService) { + this.telemetryService = telemetryService; + this.logService = logService; + this.sendRequestTelemetryCounter = 0; + this.sendSpeculativeRequestTelemetryCounter = 0; + } + + public sendSpeculativeRequestTelemetry(context: RequestContext, originalRequestId: string, numberOfItems: number): void { + const sampleTelemetry = RequestContext.getSampleTelemetry(context); + const shouldSendTelemetry = sampleTelemetry === 1 || this.sendSpeculativeRequestTelemetryCounter % sampleTelemetry === 0; + this.sendSpeculativeRequestTelemetryCounter++; + + if (shouldSendTelemetry) { + /* __GDPR__ + "typescript-context-plugin.completion-context.speculative" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "originalRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The original request id for which this is a speculative request" }, + "numberOfItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of items in the speculative request", "isMeasurement": true }, + "sampleTelemetry": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The sampling rate for telemetry. A value of 1 means every request is logged, a value of 5 means every 5th request is logged, etc.", "isMeasurement": true } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.speculative', + { + requestId: context.requestId, + source: context.source ?? KnownSources.unknown, + originalRequestId: originalRequestId + }, + { + numberOfItems: numberOfItems, + sampleTelemetry: sampleTelemetry + } + ); + } + this.logService.debug(`TypeScript Copilot context speculative request: [${context.requestId} - ${originalRequestId}, numberOfItems: ${numberOfItems}]`); + } + + public willLogRequestTelemetry(context: RequestContext): boolean { + const sampleTelemetry = RequestContext.getSampleTelemetry(context); + return sampleTelemetry === 1 || this.sendRequestTelemetryCounter % sampleTelemetry === 0; + } + + public sendRequestTelemetry(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, data: ContextItemSummary, timeTaken: number, cacheState: { before: CacheState; after: CacheState } | undefined, cacheRequest: string | undefined): void { + const stats = data.stats; + const nodePath = data?.path ? JSON.stringify(data.path) : JSON.stringify([0]); + const items = stats.items; + const totalSize = stats.totalSize; + const fileSize = document.getText().length; + + const sampleTelemetry = RequestContext.getSampleTelemetry(context); + const shouldSendTelemetry = sampleTelemetry === 1 || this.sendRequestTelemetryCounter % sampleTelemetry === 0; + this.sendRequestTelemetryCounter++; + if (shouldSendTelemetry) { + /* __GDPR__ + "typescript-context-plugin.completion-context.request" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "trigger": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The trigger kind of the request" }, + "cacheRequest": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache request that was used to populate the cache" }, + "nodePath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The syntax kind path to the AST node the position resolved to." }, + "cancelled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request got cancelled on the client side" }, + "timedOut": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request timed out on the server side" }, + "tokenBudgetExhausted": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the token budget was exhausted" }, + "serverTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken on the server side", "isMeasurement": true }, + "contextComputeTime": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken on the server side to compute the context", "isMeasurement": true }, + "timeTaken": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken to provide the completion", "isMeasurement": true }, + "total": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of context items", "isMeasurement": true }, + "snippets": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of code snippets", "isMeasurement": true }, + "traits": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of traits", "isMeasurement": true }, + "yielded": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of yielded items", "isMeasurement": true }, + "items": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Detailed information about each context item delivered." }, + "totalSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total size of all context items", "isMeasurement": true }, + "fileSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The size of the file", "isMeasurement": true }, + "cachedItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of cache items", "isMeasurement": true }, + "referencedItems": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of referenced items", "isMeasurement": true }, + "isSpeculative": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request was speculative" }, + "beforeCacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state before the request was sent" }, + "afterCacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state after the request was sent" }, + "fromCache": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the context was fully provided from cache" }, + "sampleTelemetry": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The sampling rate for telemetry. A value of 1 means every request is logged, a value of 5 means every 5th request is logged, etc.", "isMeasurement": true } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.request', + { + requestId: context.requestId, + opportunityId: context.opportunityId ?? 'unknown', + source: context.source ?? KnownSources.unknown, + trigger: context.trigger ?? TriggerKind.unknown, + cacheRequest: cacheRequest ?? 'unknown', + nodePath: nodePath, + cancelled: data.cancelled.toString(), + timedOut: data.timedOut.toString(), + tokenBudgetExhausted: data.tokenBudgetExhausted.toString(), + items: JSON.stringify(items), + isSpeculative: (context.proposedEdits !== undefined && context.proposedEdits.length > 0 ? true : false).toString(), + beforeCacheState: cacheState?.before.toString(), + afterCacheState: cacheState?.after.toString(), + fromCache: data.fromCache.toString(), + }, + { + serverTime: data.serverTime, + contextComputeTime: data.contextComputeTime, + timeTaken, + total: stats.total, + snippets: stats.snippets, + traits: stats.traits, + yielded: stats.yielded, + totalSize: totalSize, + fileSize: fileSize, + cachedItems: data.cachedItems, + referencedItems: data.referencedItems, + sampleTelemetry: sampleTelemetry + } + ); + } + this.logService.debug(`TypeScript Copilot context: [${context.requestId}, ${context.source ?? KnownSources.unknown}, ${JSON.stringify(position, undefined, 0)}, ${JSON.stringify(nodePath, undefined, 0)}, ${JSON.stringify(stats, undefined, 0)}, cacheItems:${data.cachedItems}, cacheState:${JSON.stringify(cacheState, undefined, 0)}, budgetExhausted:${data.tokenBudgetExhausted}, cancelled:${data.cancelled}, timedOut:${data.timedOut}, fileSize:${fileSize}] in [${timeTaken},${data.serverTime},${data.contextComputeTime}]ms.${data.timedOut ? ' Timed out.' : ''}`); + if (data.errorData !== undefined && data.errorData.length > 0) { + const errorData = data.errorData; + for (const error of errorData) { + /* __GDPR__ + "typescript-context-plugin.completion-context.error" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context errors", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "code": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The failure code", "isMeasurement": true }, + "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.error', + { + requestId: context.requestId, + source: context.source ?? KnownSources.unknown, + message: error.message + }, + { + code: error.code + } + ); + this.logService.error('Error computing context:', `${error.message} [${error.code}]`); + } + } + } + + public sendRequestOnTimeoutTelemetry(context: RequestContext, data: ContextItemSummary, cacheState: CacheState): void { + const stats = data.stats; + const items = stats.items; + const totalSize = stats.totalSize; + /* __GDPR__ + "typescript-context-plugin.completion-context.on-timeout" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context on timeout", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "total": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of context items", "isMeasurement": true }, + "snippets": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of code snippets", "isMeasurement": true }, + "traits": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of traits", "isMeasurement": true }, + "yielded": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of yielded items", "isMeasurement": true }, + "items": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Detailed information about each context item delivered." }, + "totalSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total size of all context items", "isMeasurement": true }, + "cacheState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The cache state for the onTimeout request" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.on-timeout', + { + requestId: context.requestId, + opportunityId: context.opportunityId ?? 'unknown', + source: context.source ?? KnownSources.unknown, + items: JSON.stringify(items), + cacheState: cacheState.toString() + }, + { + total: stats.total, + snippets: stats.snippets, + traits: stats.traits, + yielded: stats.yielded, + totalSize: totalSize + } + ); + this.logService.debug(`TypeScript Copilot context on timeout: [${context.requestId}, ${JSON.stringify(stats, undefined, 0)}]`); + } + + public sendRequestFailureTelemetry(context: RequestContext, data: { error: protocol.ErrorCode; message: string; stack?: string }): void { + /* __GDPR__ + "typescript-context-plugin.completion-context.failed" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context in failure case", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "code:": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The failure code" }, + "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" }, + "stack": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure stack" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.failed', + { + requestId: context.requestId, + opportunityId: context.opportunityId ?? 'unknown', + source: context.source ?? KnownSources.unknown, + code: data.error, + message: data.message, + stack: data.stack ?? 'Not available' + } + ); + } + + public sendRequestCancelledTelemetry(context: RequestContext, timeTaken: number): void { + /* __GDPR__ + "typescript-context-plugin.completion-context.cancelled" : { + "owner": "dirkb", + "comment": "Telemetry for copilot inline completion context in cancellation case", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity id" }, + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" }, + "timeTaken": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Time taken to provide the completion", "isMeasurement": true } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.completion-context.cancelled', + { + requestId: context.requestId, + opportunityId: context.opportunityId ?? 'unknown', + source: context.source ?? KnownSources.unknown + }, + { + timeTaken: timeTaken + } + ); + this.logService.debug(`TypeScript Copilot context request ${context.requestId} got cancelled.`); + } + + public sendActivationTelemetry(response: protocol.PingResponse | undefined, error: unknown | undefined): void { + if (response !== undefined) { + const body: protocol.PingResponse['body'] | undefined = response?.body; + if (body?.kind === 'ok') { + /* __GDPR__ + "typescript-context-plugin.activation.ok" : { + "owner": "dirkb", + "comment": "Telemetry for TypeScript server plugin", + "session": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the TypeScript server had a session" }, + "supported": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the TypeScript server version is supported" }, + "version": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version of the TypeScript server" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.activation.ok', + { + session: body.session.toString(), + supported: body.supported.toString(), + version: body.version ?? 'unknown' + } + ); + } else if (body?.kind === 'error') { + this.sendActivationFailedTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, body.message, body.stack); + } else { + this.sendUnknownPingResponseTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, response); + } + } else if (error !== undefined) { + const isError = error instanceof Error; + if (isError && TypeScriptServerError.is(error)) { + this.sendActivationFailedTelemetry(ErrorLocation.Server, ErrorPart.ServerPlugin, error.response.message ?? error.message, undefined, error.version.displayName); + } else if (isError) { + this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, error.message, error.stack); + } else { + this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, 'Unknown error', undefined); + } + } else { + this.sendActivationFailedTelemetry(ErrorLocation.Client, ErrorPart.ServerPlugin, 'Neither response nor error received.', undefined); + } + } + + public sendActivationFailedTelemetry(location: ErrorLocation, part: ErrorPart, message: string, stack?: string | undefined, version?: string | undefined): void { + /* __GDPR__ + "typescript-context-plugin.activation.failed" : { + "owner": "dirkb", + "comment": "Telemetry for TypeScript server plugin", + "location": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The location of the failure" }, + "part": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The part that errored" }, + "message": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure message" }, + "stack": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The failure stack" }, + "version": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.activation.failed', + { + location: location, + part: part, + message: message, + stack: stack ?? 'Not available', + version: version ?? 'Not specified' + } + ); + } + + private sendUnknownPingResponseTelemetry(location: ErrorLocation, part: ErrorPart, response: object): void { + /* __GDPR__ + "typescript-context-plugin.activation.unknown-ping-response" : { + "owner": "dirkb", + "comment": "Telemetry for TypeScript server plugin", + "location": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The location of the failure" }, + "part": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The part that errored" }, + "response": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The response literal" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.activation.unknown-ping-response', + { + location: location, + part: part, + response: JSON.stringify(response, undefined, 0) + } + ); + } + + public sendIntegrationTelemetry(requestId: string, document: string, versionMismatch?: string): void { + /* __GDPR__ + "typescript-context-plugin.integration.failed" : { + "owner": "dirkb", + "comment": "Telemetry for Copilot inline chat integration.", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request correlation id" }, + "document": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The document for which the integration failed" }, + "versionMismatch": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The version mismatch" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.integration.failed', + { + requestId: requestId, + document: document, + versionMismatch: versionMismatch + } + ); + } + + public sendInlineCompletionProviderTelemetry(source: KnownSources, registered: boolean): void { + if (registered) { + /* __GDPR__ + "typescript-context-plugin.inline-completion-provider.registered" : { + "owner": "dirkb", + "comment": "Telemetry for Copilot inline completions", + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.inline-completion-provider.registered', + { + source: source + } + ); + } else { + /* __GDPR__ + "typescript-context-plugin.inline-completion-provider.unregistered" : { + "owner": "dirkb", + "comment": "Telemetry for Copilot inline completions", + "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source of the request" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent( + 'typescript-context-plugin.inline-completion-provider.unregistered', + { + source: source + } + ); + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/test/tsService.spec.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/test/tsService.spec.ts new file mode 100644 index 00000000000000..d9f92d58a32f84 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/test/tsService.spec.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'node:assert'; + +import type * as vscode from 'vscode'; +import { suite, test, vi } from 'vitest'; + +vi.mock('vscode', () => ({})); + +import { TypeScript } from '../tsService'; + +suite('TypeScript service', () => { + test('prefers the current TS7 extension and falls back to the legacy extension', () => { + const currentExtensionId = 'typescriptteam.vscode-typescript'; + const legacyExtensionId = 'typescriptteam.native-preview'; + const scenarios = [ + [currentExtensionId, legacyExtensionId], + [legacyExtensionId], + [], + ]; + + const actual = scenarios.map(extensionIds => { + const available = new Map>(); + for (const extensionId of extensionIds) { + available.set(extensionId, { id: extensionId } as vscode.Extension); + } + const lookups: string[] = []; + const extension = TypeScript.getVersion7Extension(extensionId => { + lookups.push(extensionId); + return available.get(extensionId); + }); + return { selected: extension?.id, lookups }; + }); + + assert.deepStrictEqual(actual, [ + { selected: currentExtensionId, lookups: [currentExtensionId] }, + { selected: legacyExtensionId, lookups: [currentExtensionId, legacyExtensionId] }, + { selected: undefined, lookups: [currentExtensionId, legacyExtensionId] }, + ]); + }); +}); diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/api.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/api.ts new file mode 100644 index 00000000000000..b70a2a88e503d4 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/api.ts @@ -0,0 +1,299 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { API, Project, Snapshot, DocumentIdentifier } from '@typescript/native/unstable/async'; +import { + SyntaxKind, + isArrowFunction, + isClassDeclaration, + isConstructorDeclaration, + isFunctionDeclaration, + isFunctionExpression, + isGetAccessorDeclaration, + isMethodDeclaration, + isModuleDeclaration, + isSetAccessorDeclaration, + isSourceFile, + type Node, + type SourceFile, +} from '@typescript/native/unstable/ast'; +import * as protocol from '../../common/serverProtocol'; +import { CompilerOptionsRunnable } from './baseContextProviders'; +import { ClassContextProvider } from './classContextProvider'; +import { ContextProvider, ContextRunnableCollector, type ComputeContextSession, type ContextProviderFactory, type ContextResult, type ContextRunnable, type ProviderComputeContext, type RequestContext } from './contextProvider'; +import { FunctionContextProvider } from './functionContextProvider'; +import { AccessorProvider, ConstructorContextProvider, MethodContextProvider } from './methodContextProvider'; +import { ModuleContextProvider } from './moduleContextProvider'; +import { PrepareNesRenameResult, validateNesRename } from './nesRenameValidator'; +import { SourceFileContextProvider } from './sourceFileContextProvider'; +import { RecoverableError } from './types'; +import tss, { Symbols, type CancellationTokenWithTimer } from './typescripts'; + +class ProviderComputeContextImpl implements ProviderComputeContext { + private firstCallableProvider: ContextProvider | undefined; + + public update(contextProvider: ContextProvider): ContextProvider { + if (this.firstCallableProvider === undefined && contextProvider.isCallableProvider === true) { + this.firstCallableProvider = contextProvider; + } + return contextProvider; + } + + public isFirstCallableProvider(contextProvider: ContextProvider): boolean { + return this.firstCallableProvider === contextProvider; + } +} + +class ContextProviders { + private static readonly Factories = new Map([ + [SyntaxKind.SourceFile, (_node, tokenInfo, computeContext) => new SourceFileContextProvider(tokenInfo, computeContext)], + [SyntaxKind.FunctionDeclaration, (node, tokenInfo, computeContext) => isFunctionDeclaration(node) ? new FunctionContextProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.ArrowFunction, (node, tokenInfo, computeContext) => isArrowFunction(node) ? new FunctionContextProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.FunctionExpression, (node, tokenInfo, computeContext) => isFunctionExpression(node) ? new FunctionContextProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.GetAccessor, (node, tokenInfo, computeContext) => isGetAccessorDeclaration(node) ? new AccessorProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.SetAccessor, (node, tokenInfo, computeContext) => isSetAccessorDeclaration(node) ? new AccessorProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.ClassDeclaration, (node, tokenInfo) => isClassDeclaration(node) ? ClassContextProvider.create(node, tokenInfo) : undefined], + [SyntaxKind.Constructor, (node, tokenInfo, computeContext) => isConstructorDeclaration(node) ? new ConstructorContextProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.MethodDeclaration, (node, tokenInfo, computeContext) => isMethodDeclaration(node) ? new MethodContextProvider(node, tokenInfo, computeContext) : undefined], + [SyntaxKind.ModuleDeclaration, (node, tokenInfo, computeContext) => isModuleDeclaration(node) ? new ModuleContextProvider(node, tokenInfo, computeContext) : undefined], + ]); + + private readonly tokenInfo: tss.TokenInfo; + private readonly computeInfo: ProviderComputeContextImpl = new ProviderComputeContextImpl(); + + constructor(tokenInfo: tss.TokenInfo) { + this.tokenInfo = tokenInfo; + } + + public async execute(result: ContextResult, session: ComputeContextSession, project: Project, token: CancellationTokenWithTimer): Promise { + const collector = await this.getContextRunnables(session, project, result.context, token); + result.addPath(tss.StableSyntaxKinds.getPath(this.tokenInfo.touching ?? this.tokenInfo.token)); + for (const runnable of collector.entries()) { + runnable.initialize(result); + } + await this.executeRunnables(collector.getPrimaryRunnables(), result, token); + await this.executeRunnables(collector.getSecondaryRunnables(), result, token); + await this.executeRunnables(collector.getTertiaryRunnables(), result, token); + result.done(); + } + + private async executeRunnables(runnables: ContextRunnable[], result: ContextResult, token: CancellationTokenWithTimer): Promise { + for (const runnable of runnables) { + token.throwIfCancellationRequested(); + try { + await runnable.compute(token); + } catch (error) { + if (error instanceof RecoverableError) { + result.addErrorData(error); + } else { + throw error; + } + } + } + } + + private async getContextRunnables(session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + const result = new ContextRunnableCollector(context.clientSideRunnableResults); + result.addPrimary(new CompilerOptionsRunnable(session, project, context, this.tokenInfo.token.getSourceFile())); + for (const provider of this.computeProviders()) { + await provider.provide(result, session, project, context, token); + } + return result; + } + + private computeProviders(): ContextProvider[] { + const result: ContextProvider[] = []; + let token: Node | undefined = this.tokenInfo.touching; + if (token === undefined) { + token = this.tokenInfo.token.kind === SyntaxKind.EndOfFile ? this.tokenInfo.previous : this.tokenInfo.token; + } + if (token === undefined || token.kind === SyntaxKind.EndOfFile) { + return result; + } + let current: Node | undefined = token; + while (current !== undefined) { + const factory = ContextProviders.Factories.get(current.kind); + const provider = factory?.(current, this.tokenInfo, this.computeInfo); + if (provider !== undefined) { + result.push(this.computeInfo.update(provider)); + } + if (isSourceFile(current)) { + break; + } + current = current.parent; + } + return result; + } +} + +export async function computeContext(result: ContextResult, session: ComputeContextSession, project: Project, document: SourceFile, position: number, token: CancellationTokenWithTimer): Promise { + const sourceFile = await project.program.getSourceFile(document.fileName); + if (sourceFile === undefined) { + result.addErrorData(new RecoverableError('No source file found for document', RecoverableError.NoSourceFile)); + return; + } + const tokenInfo = tss.getRelevantTokens(sourceFile, position); + await new ContextProviders(tokenInfo).execute(result, session, project, token); +} + +export async function prepareNesRename(result: PrepareNesRenameResult, api: API, snapshot: Snapshot, project: Project, document: SourceFile, position: number, oldName: string | undefined, newName: string | undefined, lastSymbolRename: protocol.Range | undefined, token: CancellationTokenWithTimer): Promise { + if (typeof oldName !== 'string' || oldName.length === 0) { + result.setCanRename(protocol.RenameKind.no, 'No old name provided'); + return; + } + if (typeof newName !== 'string' || newName.length === 0) { + result.setCanRename(protocol.RenameKind.no, 'No new name provided'); + return; + } + + const state = await doPrepareNesRename(result, project, document, position, oldName, newName, token); + if (state !== PrepareState.unavailable || lastSymbolRename === undefined) { + return; + } + + const [oldText, oldPosition] = getOldText(document, position, oldName, newName, lastSymbolRename); + await runWithTemporaryFileUpdate(api, snapshot, document.fileName, oldText, async updatedSnapshot => { + const updatedProject = await getUpdatedProject(updatedSnapshot, project, document.fileName); + const updatedSourceFile = await updatedProject?.program.getSourceFile(document.fileName); + if (updatedProject === undefined || updatedSourceFile === undefined) { + result.setCanRename(protocol.RenameKind.no, 'No source file found for document'); + return; + } + const updatedState = await doPrepareNesRename(result, updatedProject, updatedSourceFile, oldPosition, oldName, newName, token); + if (updatedState === PrepareState.prepared && (result.getCanRename() === protocol.RenameKind.maybe || result.getCanRename() === protocol.RenameKind.yes)) { + result.setOnOldState(true); + } + }); +} + +export async function nesRename(api: API, snapshot: Snapshot, project: Project, document: SourceFile, position: number, oldName: string | undefined, newName: string | undefined, lastSymbolRename: protocol.Range | undefined, token: CancellationTokenWithTimer): Promise { + if (oldName === undefined || newName === undefined || lastSymbolRename === undefined) { + return []; + } + + const [oldText, oldPosition] = getOldText(document, position, oldName, newName, lastSymbolRename); + const groups = new Map(); + const seen = new Set(); + await runWithTemporaryFileUpdate(api, snapshot, document.fileName, oldText, async updatedSnapshot => { + const updatedProject = await getUpdatedProject(updatedSnapshot, project, document.fileName); + const updatedSourceFile = await updatedProject?.program.getSourceFile(document.fileName); + if (updatedProject === undefined || updatedSourceFile === undefined) { + return; + } + const renameTarget = getRenameTarget(updatedSourceFile, oldPosition, oldName); + if (renameTarget.node.getText(updatedSourceFile) !== oldName) { + return; + } + const symbols = new Symbols(updatedProject, token); + const referencedSymbols = await updatedProject.checker.getReferencedSymbolsForNode(renameTarget.node, renameTarget.position); + for (const referencedSymbol of referencedSymbols) { + const definition = await referencedSymbol.definition.resolve(updatedProject); + if (definition === undefined || await symbols.isSourceFileFromLibrary(definition.getSourceFile())) { + return; + } + } + for (const referencedSymbol of referencedSymbols) { + for (const reference of referencedSymbol.references) { + token.throwIfCancellationRequested(); + const node = await reference.resolve(updatedProject); + if (node === undefined) { + continue; + } + const sourceFile = node.getSourceFile(); + if (await symbols.isSourceFileFromLibrary(sourceFile)) { + continue; + } + const startPosition = node.getStart(sourceFile); + const endPosition = node.getEnd(); + const key = `${sourceFile.path}:${startPosition}:${endPosition}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + const start = sourceFile.getLineAndCharacterOfPosition(startPosition); + const end = sourceFile.getLineAndCharacterOfPosition(endPosition); + const delta = newName.length - oldName.length; + if ( + sourceFile.fileName === document.fileName && + start.line === lastSymbolRename.start.line && start.character === lastSymbolRename.start.character && + end.line === lastSymbolRename.end.line && end.character === lastSymbolRename.end.character - delta + ) { + continue; + } + let group = groups.get(sourceFile.fileName); + if (group === undefined) { + group = { file: sourceFile.fileName, changes: [] }; + groups.set(sourceFile.fileName, group); + } + group.changes.push({ + range: { + start: { line: start.line, character: start.character }, + end: { line: end.line, character: end.character }, + }, + }); + } + } + }); + return Array.from(groups.values()); +} + +function runWithTemporaryFileUpdate(api: API, baseSnapshot: Snapshot, file: DocumentIdentifier, newText: string, cb: (newSnapshot: Snapshot) => void | Promise): Promise { + interface ApiWithTemporaryFileUpdate { + runWithTemporaryFileUpdate(baseSnapshot: Snapshot, file: DocumentIdentifier, newText: string, cb: (newSnapshot: Snapshot) => void | Promise): Promise; + } + if (typeof (api as unknown as ApiWithTemporaryFileUpdate).runWithTemporaryFileUpdate === 'function') { + return (api as unknown as ApiWithTemporaryFileUpdate).runWithTemporaryFileUpdate(baseSnapshot, file, newText, cb); + } + return Promise.resolve(); +} + +const enum PrepareState { + prepared, + unavailable, + mismatch, +} + +async function doPrepareNesRename(result: PrepareNesRenameResult, project: Project, sourceFile: SourceFile, position: number, oldName: string, newName: string, token: CancellationTokenWithTimer): Promise { + const renameTarget = getRenameTarget(sourceFile, position, oldName); + const tokenText = renameTarget.node.getText(sourceFile); + if (tokenText !== oldName) { + result.setCanRename(protocol.RenameKind.no, `Old name '${oldName}' does not match symbol name '${tokenText}'`); + return PrepareState.mismatch; + } + token.throwIfCancellationRequested(); + if (await project.checker.getSymbolAtLocation(renameTarget.node) === undefined) { + result.setCanRename(protocol.RenameKind.no, 'No symbol found at location'); + return PrepareState.unavailable; + } + result.setCanRename(protocol.RenameKind.maybe, oldName); + await validateNesRename(result, project, renameTarget.node, oldName, newName, token); + return PrepareState.prepared; +} + +function getRenameTarget(sourceFile: SourceFile, position: number, oldName: string): { node: Node; position: number } { + const token = tss.getRelevantTokens(sourceFile, position).token; + if (token.getText(sourceFile) === oldName) { + return { node: token, position }; + } + let current: Node | undefined = token.parent; + while (current !== undefined && !isSourceFile(current)) { + if (isFunctionDeclaration(current) && current.name?.getText(sourceFile) === oldName) { + return { node: current.name, position: current.name.getStart(sourceFile) }; + } + current = current.parent; + } + return { node: token, position }; +} + +async function getUpdatedProject(snapshot: Snapshot, project: Project, fileName: string): Promise { + return snapshot.getProject(project.configFileName) ?? await snapshot.getDefaultProjectForFile(fileName); +} + +function getOldText(sourceFile: SourceFile, position: number, oldName: string, newName: string, lastSymbolRename: protocol.Range): [string, number] { + const startPosition = sourceFile.getPositionOfLineAndCharacter(lastSymbolRename.start.line, lastSymbolRename.start.character); + const endPosition = sourceFile.getPositionOfLineAndCharacter(lastSymbolRename.end.line, lastSymbolRename.end.character); + const oldText = sourceFile.text.substring(0, startPosition) + oldName + sourceFile.text.substring(endPosition); + return [oldText, position < startPosition ? position : position - (newName.length - oldName.length)]; +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/baseContextProviders.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/baseContextProviders.ts new file mode 100644 index 00000000000000..ffcebb908ce03a --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/baseContextProviders.ts @@ -0,0 +1,523 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createHash } from 'node:crypto'; + +import { version } from '@typescript/native'; +import { ModuleKind, SignatureKind, SymbolFlags, type Project, type Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import { + ScriptTarget, + SyntaxKind, + isArrowFunction, + isBlock, + isCallExpression, + isElementAccessExpression, + isFunctionDeclaration, + isFunctionExpression, + isIdentifier, + isImportDeclaration, + isIntersectionTypeNode, + isNamedImports, + isNamespaceImport, + isPropertyAccessExpression, + isTypeLiteralNode, + isTypeReferenceNode, + isUnionTypeNode, + type FunctionLikeDeclaration, + type ImportDeclaration, + type Node, + type SourceFile, + type TypeNode, + type VariableDeclaration, +} from '@typescript/native/unstable/ast'; +import * as protocol from '../../common/serverProtocol'; +import { + AbstractContextRunnable, + CacheScopes, + ComputeCost, + ContextProvider, + SnippetLocation, + type ComputeContextSession, + type ContextResult, + type ContextRunnableCollector, + type ProviderComputeContext, + type RequestContext, + type RunnableResult, + type SymbolData, +} from './contextProvider'; +import tss, { type CancellationTokenWithTimer } from './typescripts'; + +export class CompilerOptionsRunnable extends AbstractContextRunnable { + private readonly sourceFile: SourceFile; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, sourceFile: SourceFile) { + super(session, project, context, 'CompilerOptionsRunnable', SnippetLocation.Primary, protocol.Priorities.Traits, ComputeCost.Low); + this.sourceFile = sourceFile; + } + + public override getActiveSourceFile(): SourceFile { + return this.sourceFile; + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + const cacheInfo: protocol.CacheInfo = { emitMode: protocol.EmitMode.ClientBased, scope: { kind: protocol.CacheScopeKind.File } }; + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, cacheInfo); + } + + protected override async run(result: RunnableResult): Promise { + const compilerOptions = this.getProject().program.getCompilerOptions(); + this.addTrait(result, protocol.TraitKind.Version, 'The TypeScript version used in this project is ', version); + this.addTrait(result, protocol.TraitKind.Module, 'The TypeScript module system used in this project is ', compilerOptions.module === undefined ? undefined : ModuleKind[compilerOptions.module]); + this.addTrait(result, protocol.TraitKind.ModuleResolution, 'The TypeScript module resolution strategy used in this project is ', compilerOptions.moduleResolution === undefined ? undefined : this.moduleResolutionName(compilerOptions.moduleResolution)); + this.addTrait(result, protocol.TraitKind.Target, 'The target version of JavaScript for this project is ', compilerOptions.target === undefined ? undefined : ScriptTarget[compilerOptions.target]); + this.addTrait(result, protocol.TraitKind.Lib, 'Library files that should be included in TypeScript compilation are ', compilerOptions.lib?.toString()); + } + + private addTrait(result: RunnableResult, kind: protocol.TraitKind, name: string, value: string | undefined): void { + if (value === undefined) { + return; + } + const key = protocol.Trait.createContextItemKey(kind); + if (!result.addFromKnownItems(key)) { + result.addTrait(kind, name, value, key); + } + } + + private moduleResolutionName(value: number): string { + switch (value) { + case 1: return 'Classic'; + case 2: return 'Node10'; + case 3: return 'Node16'; + case 99: return 'NodeNext'; + case 100: return 'Bundler'; + default: return 'Unknown'; + } + } +} + +export abstract class FunctionLikeContextRunnable extends AbstractContextRunnable { + protected readonly declaration: T; + protected readonly sourceFile: SourceFile; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, id: string, declaration: T, priority: number, cost: ComputeCost) { + super(session, project, context, id, SnippetLocation.Primary, priority, cost); + this.declaration = declaration; + this.sourceFile = declaration.getSourceFile(); + } + + public override getActiveSourceFile(): SourceFile { + return this.sourceFile; + } + + protected getCacheScope(): protocol.CacheScope | undefined { + return this.declaration.body === undefined || !isBlock(this.declaration.body) + ? undefined + : this.createCacheScope(this.declaration.body, this.sourceFile); + } +} + +export class SignatureRunnable extends FunctionLikeContextRunnable { + constructor(session: ComputeContextSession, project: Project, context: RequestContext, declaration: FunctionLikeDeclaration, priority: number = protocol.Priorities.Locals) { + super(session, project, context, SignatureRunnable.computeId(session, declaration), declaration, priority, ComputeCost.Low); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + const scope = this.getCacheScope(); + const cacheInfo = scope === undefined ? undefined : { emitMode: protocol.EmitMode.ClientBased, scope }; + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, cacheInfo); + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + for (const parameter of this.declaration.parameters) { + token.throwIfCancellationRequested(); + if (parameter.type !== undefined) { + await this.processType(parameter.type, token); + } + } + if (this.declaration.type !== undefined) { + token.throwIfCancellationRequested(); + await this.processType(this.declaration.type, token); + } + } + + private async processType(type: TypeNode, token: CancellationTokenWithTimer): Promise { + for (const symbolEmitData of await this.getSymbolsForTypeNode(type)) { + token.throwIfCancellationRequested(); + await this.handleSymbol(symbolEmitData.symbol, symbolEmitData.name); + } + } + + private static computeId(_session: ComputeContextSession, declaration: FunctionLikeDeclaration): string { + const end = declaration.type?.end ?? declaration.parameters.end; + const hash = createHash('md5'); // CodeQL [SM04514] Used only as a compact cache key, not for security. + hash.update(declaration.getSourceFile().fileName); + hash.update(`[${declaration.parameters.pos},${end}]`); + return `SignatureRunnable:${hash.digest('base64')}`; + } +} + +export class TypeOfLocalsRunnable extends AbstractContextRunnable { + private readonly tokenInfo: tss.TokenInfo; + private readonly excludes: Set; + private readonly cacheScope: protocol.CacheScope | undefined; + private runnableResult: RunnableResult | undefined; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, tokenInfo: tss.TokenInfo, excludes: Set, cacheScope: protocol.CacheScope | undefined, priority: number = protocol.Priorities.Locals) { + super(session, project, context, 'TypeOfLocalsRunnable', SnippetLocation.Primary, priority, ComputeCost.Medium); + this.tokenInfo = tokenInfo; + this.excludes = excludes; + this.cacheScope = cacheScope; + } + + public override getActiveSourceFile(): SourceFile { + return this.tokenInfo.token.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + const cacheInfo = this.cacheScope === undefined ? undefined : { emitMode: protocol.EmitMode.ClientBasedOnTimeout, scope: this.cacheScope }; + this.runnableResult = result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, cacheInfo); + return this.runnableResult; + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const anchor = this.tokenInfo.previous ?? this.tokenInfo.token ?? this.tokenInfo.touching; + const symbols = this.symbols; + const checker = symbols.getTypeChecker(); + const sourceFile = anchor.getSourceFile(); + // The AST navigation helpers hand out synthesized token nodes, so the checker can only resolve them via a document position. + const inScope = await symbols.getSymbolsInScope({ document: sourceFile.fileName, position: anchor.getStart(sourceFile) }, SymbolFlags.BlockScopedVariable); + if (inScope.length === 0) { + return; + } + + // When we try to capture locals outside of a callable (e.g. top level in a source file) we capture the declarations as + // scope. If we are inside the body of the callable defines the scope. + const cacheNodes = this.cacheScope === undefined ? new Set() : undefined; + // The symbols are block scope variables. We try to find the type of the variable + // to include it in the context. + for (const symbol of inScope) { + token.throwIfCancellationRequested(); + if (this.excludes.has(symbol)) { + continue; + } + const declaration: VariableDeclaration | undefined = await symbols.getDeclaration(symbol, SyntaxKind.VariableDeclaration); + if (declaration === undefined) { + continue; + } + let symbolsToEmit: SymbolData[] | undefined = undefined; + if (declaration.type !== undefined) { + symbolsToEmit = await this.getSymbolsForTypeNode(declaration.type); + } else { + const type = await checker.getTypeAtLocation(declaration.type ?? declaration); + if (type !== undefined) { + symbolsToEmit = await this.getSymbolsToEmitForType(type); + } + } + if (symbolsToEmit === undefined || symbolsToEmit.length === 0) { + continue; + } + for (const { symbol, name } of symbolsToEmit) { + token.throwIfCancellationRequested(); + await this.handleSymbol(symbol, name); + } + + + if (cacheNodes !== undefined) { + const declarationList = tss.Nodes.getParentOfKind(declaration, SyntaxKind.VariableDeclarationList); + if (declarationList !== undefined) { + cacheNodes.add(declarationList); + } + } + } + if (cacheNodes !== undefined && cacheNodes.size > 0 && this.runnableResult !== undefined) { + this.runnableResult.setCacheInfo({ emitMode:protocol.EmitMode.ClientBasedOnTimeout, scope: CacheScopes.createOutsideCacheScope(cacheNodes, sourceFile) }); + } + } +} + +export class TypesOfNeighborFilesRunnable extends AbstractContextRunnable { + private readonly tokenInfo: tss.TokenInfo; + private static readonly SymbolsToInclude: number = SymbolFlags.Class | SymbolFlags.Interface | SymbolFlags.TypeAlias | SymbolFlags.RegularEnum | SymbolFlags.ConstEnum | SymbolFlags.Function; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, tokenInfo: tss.TokenInfo, priority: number = protocol.Priorities.NeighborFiles) { + super(session, project, context, 'TypesOfNeighborFilesRunnable', SnippetLocation.Secondary, priority, ComputeCost.Medium); + this.tokenInfo = tokenInfo; + } + + public override getActiveSourceFile(): SourceFile { + return this.tokenInfo.token.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, { emitMode: protocol.EmitMode.ClientBased, scope: { kind: protocol.CacheScopeKind.NeighborFiles } }); + } + + protected override async run(result: RunnableResult, token: CancellationTokenWithTimer): Promise { + for (const neighborFile of this.context.neighborFiles) { + token.throwIfCancellationRequested(); + if (result.isSecondaryBudgetExhausted()) { + return; + } + const neighborSourceFile = await this.getProject().program.getSourceFile(neighborFile); + if (neighborSourceFile === undefined || await this.skipSourceFile(neighborSourceFile)) { + continue; + } + const sourceFileSymbol = await this.symbols.getLeafSymbolAtLocation(neighborSourceFile); + if (sourceFileSymbol === undefined) { + continue; + } + for (const [name, member] of await sourceFileSymbol.getExports()) { + if ((member.flags & TypesOfNeighborFilesRunnable.SymbolsToInclude) !== 0 && !await this.handleSymbol(member, name, true)) { + return; + } + } + } + } +} + +type ImportBlock = { before: Node | undefined; imports: ImportDeclaration[]; after: Node | undefined }; + +export class ImportsRunnable extends AbstractContextRunnable { + private readonly tokenInfo: tss.TokenInfo; + private readonly excludes: Set; + private cacheInfo: protocol.CacheInfo | undefined; + private runnableResult: RunnableResult | undefined; + + private static readonly CacheNodes = new Set([ + SyntaxKind.FunctionDeclaration, + SyntaxKind.ArrowFunction, + SyntaxKind.FunctionExpression, + SyntaxKind.Constructor, + SyntaxKind.MethodDeclaration, + SyntaxKind.ClassDeclaration, + SyntaxKind.ModuleDeclaration, + ]); + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, tokenInfo: tss.TokenInfo, excludes: Set, priority: number = protocol.Priorities.Imports) { + super(session, project, context, 'ImportsRunnable', SnippetLocation.Secondary, priority, ComputeCost.Medium); + this.tokenInfo = tokenInfo; + this.excludes = excludes; + const scopeNode = this.getCacheScopeNode(); + this.cacheInfo = scopeNode === undefined ? undefined : { emitMode: protocol.EmitMode.ClientBased, scope: this.createCacheScope(scopeNode) }; + } + + public override getActiveSourceFile(): SourceFile { + return this.tokenInfo.token.getSourceFile(); + } + + public override useCachedResult(cached: protocol.CachedContextRunnableResult): boolean { + if (cached.cache?.emitMode === protocol.EmitMode.ClientBased && cached.state === protocol.ContextRunnableState.Finished) { + if (cached.cache.scope.kind === protocol.CacheScopeKind.WithinRange) { + return true; + } + if (cached.cache.scope.kind === protocol.CacheScopeKind.OutsideRange) { + return this.cacheInfo === undefined; + } + } + return super.useCachedResult(cached); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + this.runnableResult = result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, this.cacheInfo); + return this.runnableResult; + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const sourceFile = this.getActiveSourceFile(); + const importBlocks = this.getImportBlocks(sourceFile); + const importedSymbols: { symbol: NativeSymbol; name: string }[] = []; + const outsideRanges: protocol.Range[] = []; + for (const block of importBlocks) { + for (const statement of block.imports) { + token.throwIfCancellationRequested(); + const importClause = statement.importClause; + if (importClause?.name !== undefined) { + await this.addImportedSymbol(importedSymbols, importClause.name); + } + const bindings = importClause?.namedBindings; + if (bindings !== undefined) { + if (isNamespaceImport(bindings)) { + await this.addImportedSymbol(importedSymbols, bindings.name); + } else if (isNamedImports(bindings)) { + for (const element of bindings.elements) { + await this.addImportedSymbol(importedSymbols, element.name); + } + } + } + } + if (this.cacheInfo === undefined && block.imports.length > 0) { + outsideRanges.push({ + start: block.before === undefined ? CacheScopes.createRange(block.imports[0], sourceFile).start : CacheScopes.createRange(block.before, sourceFile).end, + end: block.after === undefined ? CacheScopes.createRange(block.imports.at(-1) ?? block.imports[0], sourceFile).end : CacheScopes.createRange(block.after, sourceFile).start, + }); + } + } + for (const { symbol, name } of importedSymbols) { + if ((symbol.flags & (SymbolFlags.Class | SymbolFlags.Interface | SymbolFlags.TypeAlias | SymbolFlags.RegularEnum | SymbolFlags.ConstEnum | SymbolFlags.Alias | SymbolFlags.ValueModule)) !== 0 && !await this.handleSymbol(symbol, name, true)) { + break; + } + } + if (this.cacheInfo === undefined && outsideRanges.length > 0) { + this.runnableResult?.setCacheInfo({ emitMode: protocol.EmitMode.ClientBased, scope: { kind: protocol.CacheScopeKind.OutsideRange, ranges: outsideRanges } }); + } + } + + private async addImportedSymbol(result: { symbol: NativeSymbol; name: string }[], node: Node): Promise { + const symbol = await this.symbols.getLeafSymbolAtLocation(node); + if (symbol !== undefined && !this.excludes.has(symbol)) { + result.push({ symbol, name: node.getText() }); + } + } + + private getImportBlocks(sourceFile: SourceFile): ImportBlock[] { + if (this.cacheInfo !== undefined) { + return [{ before: undefined, imports: sourceFile.statements.filter(isImportDeclaration), after: undefined }]; + } + const result: ImportBlock[] = []; + let before: Node | undefined; + let imports: ImportDeclaration[] = []; + for (const node of sourceFile.statements) { + if (isImportDeclaration(node)) { + imports.push(node); + } else if (imports.length === 0) { + before = node; + } else { + result.push({ before, imports, after: node }); + before = undefined; + imports = []; + } + } + if (imports.length > 0) { + result.push({ before, imports, after: undefined }); + } + return result; + } + + private getCacheScopeNode(): Node | undefined { + let current: Node | undefined = this.tokenInfo.touching ?? this.tokenInfo.token; + let result: Node | undefined; + while (current !== undefined && current.kind !== SyntaxKind.SourceFile) { + if (ImportsRunnable.CacheNodes.has(current.kind)) { + result = current; + } + current = current.parent; + } + return result; + } +} + +export class TypeOfExpressionRunnable extends AbstractContextRunnable { + private readonly expression: Node; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, expression: Node, priority: number = protocol.Priorities.Expression) { + super(session, project, context, 'TypeOfExpressionRunnable', SnippetLocation.Primary, priority, ComputeCost.Low); + this.expression = expression; + } + + public override getActiveSourceFile(): SourceFile { + return this.expression.getSourceFile(); + } + + public static create(session: ComputeContextSession, project: Project, context: RequestContext, tokenInfo: tss.TokenInfo, _token: CancellationTokenWithTimer): TypeOfExpressionRunnable | undefined { + const previous = tokenInfo.previous; + if (previous !== undefined && (isIdentifier(previous) || previous.kind === SyntaxKind.DotToken) && isPropertyAccessExpression(previous.parent)) { + const identifier = this.getRightMostIdentifier(previous.parent.expression, 0); + if (identifier !== undefined) { + return new TypeOfExpressionRunnable(session, project, context, identifier); + } + } + return undefined; + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.ignore); + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const expressionSymbol = await this.symbols.getLeafSymbolAtLocation(this.expression); + if (expressionSymbol === undefined) { + return; + } + const checker = this.getProject().checker; + const type = await checker.getTypeOfSymbolAtLocation(expressionSymbol, this.expression); + for (const signature of [ + ...await checker.getSignaturesOfType(type, SignatureKind.Construct), + ...await checker.getSignaturesOfType(type, SignatureKind.Call), + ]) { + token.throwIfCancellationRequested(); + const returnType = await checker.getReturnTypeOfSignature(signature); + if (returnType === undefined) { + continue; + } + for (const symbol of await this.symbols.getTypeSymbols(returnType)) { + await this.handleSymbol(symbol, symbol.name); + } + } + for (const symbol of await this.symbols.getTypeSymbols(type)) { + await this.handleSymbol(symbol, symbol.name); + } + } + + private static getRightMostIdentifier(node: Node, count: number): Node | undefined { + if (count === 32) { + return undefined; + } + if (isIdentifier(node)) { + return node; + } + if (isPropertyAccessExpression(node)) { + return this.getRightMostIdentifier(node.name, count + 1); + } + if (isElementAccessExpression(node)) { + return node.argumentExpression === undefined ? undefined : this.getRightMostIdentifier(node.argumentExpression, count + 1); + } + if (isCallExpression(node)) { + return this.getRightMostIdentifier(node.expression, count + 1); + } + return undefined; + } +} + +export abstract class FunctionLikeContextProvider extends ContextProvider { + protected readonly functionLikeDeclaration: FunctionLikeDeclaration; + protected readonly tokenInfo: tss.TokenInfo; + protected readonly computeContext: ProviderComputeContext; + public override readonly isCallableProvider: boolean = true; + + constructor(declaration: FunctionLikeDeclaration, tokenInfo: tss.TokenInfo, computeContext: ProviderComputeContext) { + super(); + this.functionLikeDeclaration = declaration; + this.tokenInfo = tokenInfo; + this.computeContext = computeContext; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + token.throwIfCancellationRequested(); + result.addPrimary(new SignatureRunnable(session, project, context, this.functionLikeDeclaration)); + if (!this.computeContext.isFirstCallableProvider(this)) { + return; + } + const excludes = await this.getTypeExcludes(project, context); + result.addPrimary(new TypeOfLocalsRunnable(session, project, context, this.tokenInfo, excludes, CacheScopes.fromDeclaration(this.functionLikeDeclaration))); + const expression = TypeOfExpressionRunnable.create(session, project, context, this.tokenInfo, token); + if (expression !== undefined) { + result.addPrimary(expression); + } + result.addSecondary(new ImportsRunnable(session, project, context, this.tokenInfo, excludes)); + if (context.neighborFiles.length > 0) { + result.addTertiary(new TypesOfNeighborFilesRunnable(session, project, context, this.tokenInfo)); + } + } + + protected abstract getTypeExcludes(project: Project, context: RequestContext): Promise>; +} + +export function isFunctionContextNode(node: Node): node is FunctionLikeDeclaration { + return isFunctionDeclaration(node) || isFunctionExpression(node) || isArrowFunction(node); +} + +export function isCompositeTypeNode(node: TypeNode): boolean { + return isTypeReferenceNode(node) || isTypeLiteralNode(node) || isUnionTypeNode(node) || isIntersectionTypeNode(node); +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/classContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/classContextProvider.ts new file mode 100644 index 00000000000000..d0e771b938e5d3 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/classContextProvider.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project, Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import { isClassDeclaration, SyntaxKind, type ClassDeclaration, type Node, type SourceFile, type ExpressionWithTypeArguments } from '@typescript/native/unstable/ast'; +import { CodeSnippetBuilder } from './code'; +import { AbstractContextRunnable, ComputeCost, ContextProvider, Search, SnippetLocation, type ComputeContextSession, type ContextResult, type ContextRunnableCollector, type RequestContext, type RunnableResult } from './contextProvider'; +import * as protocol from '../../common/serverProtocol'; +import tss, { type CancellationTokenWithTimer, Symbols } from './typescripts'; + +export type TypeInfo = { + symbol: NativeSymbol; + type: ExpressionWithTypeArguments; + abstractMembers: number; +}; + +export type SimilarClassDeclaration = { + declaration: ClassDeclaration; + matchesAbstractMembers: number; +}; + +export class ClassBlueprintSearch extends Search { + private readonly classDeclaration: ClassDeclaration; + + public abstractMembers: number = 0; + public extends: TypeInfo | undefined; + public implements: readonly TypeInfo[] | undefined; + + private initialized: boolean = false; + + constructor(project: Project, symbols: Symbols, classDeclaration: ClassDeclaration) { + super(project, symbols); + this.classDeclaration = classDeclaration; + } + + public override with(project: Project, symbols: Symbols): ClassBlueprintSearch { + return project === this.project ? this : new ClassBlueprintSearch(project, symbols, this.classDeclaration); + } + + public *all(): IterableIterator { + if (this.extends !== undefined) { + yield this.extends; + } + if (this.implements !== undefined) { + yield* this.implements; + } + } + + public override async score(_project: Project, _context: RequestContext): Promise { + await this.initialize(); + return this.extends === undefined && this.implements === undefined ? -1 : 1; + } + + public override async run(_context: RequestContext, token: CancellationTokenWithTimer): Promise { + await this.initialize(); + let result: SimilarClassDeclaration | undefined; + const matches = new Map(); + for (const typeInfo of this.all()) { + token.throwIfCancellationRequested(); + // const node = isExpressionWithTypeArguments(typeInfo.type) ? typeInfo.type.expression : typeInfo.type.typeName; + const node = typeInfo.type.expression; + for (const entry of await this.project.checker.getReferencedSymbolsForNode(node, node.getStart())) { + for (const reference of entry.references) { + const node = await reference.resolve(this.project); + const candidate = node === undefined ? undefined : this.getContainingClass(node); + if (candidate === undefined || this.isSame(candidate)) { + continue; + } + matches.set(candidate, (matches.get(candidate) ?? 0) + typeInfo.abstractMembers); + } + } + } + for (const [declaration, matchesAbstractMembers] of matches) { + if (result === undefined || matchesAbstractMembers > result.matchesAbstractMembers) { + result = { declaration, matchesAbstractMembers }; + } + } + return result; + } + + private async initialize(): Promise { + if (this.initialized) { + return; + } + this.initialized = true; + const implemented: TypeInfo[] = []; + for (const heritageClause of this.classDeclaration.heritageClauses ?? []) { + for (const type of heritageClause.types) { + // const symbol = await (isExpressionWithTypeArguments(type) ? this.symbols.getLeafSymbolAtLocation(type.expression) : this.symbols.getLeafSymbolAtLocation(type.typeName)); + const symbol = await this.symbols.getLeafSymbolAtLocation(type.expression); + if (symbol === undefined) { + continue; + } + const abstractMembers = (await symbol.getMembers()).size; + this.abstractMembers += abstractMembers; + const info = { symbol, type, abstractMembers }; + if (heritageClause.token === SyntaxKind.ExtendsKeyword) { + this.extends = info; + } else { + implemented.push(info); + } + } + } + this.implements = implemented.length === 0 ? undefined : implemented.sort((first, second) => second.abstractMembers - first.abstractMembers); + } + + private isSame(other: ClassDeclaration): boolean { + return this.classDeclaration === other || (this.classDeclaration.getSourceFile().path === other.getSourceFile().path && this.classDeclaration.pos === other.pos); + } + + private getContainingClass(node: Node): ClassDeclaration | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (isClassDeclaration(current)) { + return current; + } + current = current.parent; + } + return undefined; + } +} + +export class SuperClassRunnable extends AbstractContextRunnable { + private readonly classDeclaration: ClassDeclaration; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, classDeclaration: ClassDeclaration, priority: number = protocol.Priorities.Inherited) { + super(session, project, context, 'SuperClassRunnable', SnippetLocation.Primary, priority, ComputeCost.Medium); + this.classDeclaration = classDeclaration; + } + + public override getActiveSourceFile(): SourceFile { + return this.classDeclaration.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, { emitMode: protocol.EmitMode.ClientBased, scope: this.createCacheScope(this.classDeclaration.members, this.classDeclaration.getSourceFile()) }); + } + + protected override async run(_result: RunnableResult): Promise { + const clazz = await this.symbols.getLeafSymbolAtLocation(this.classDeclaration.name ?? this.classDeclaration); + if (!Symbols.isClass(clazz)) { + return; + } + const direct = await this.symbols.getDirectSuperSymbols(clazz); + if (direct?.extends !== undefined) { + await this.handleSymbol(direct.extends.symbol, direct.extends.name); + } + for (const implemented of direct?.implements ?? []) { + await this.handleSymbol(implemented.symbol, implemented.name); + } + } +} + +class SimilarClassRunnable extends AbstractContextRunnable { + private readonly classDeclaration: ClassDeclaration; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, classDeclaration: ClassDeclaration, priority: number = protocol.Priorities.Blueprints) { + super(session, project, context, 'SimilarClassRunnable', SnippetLocation.Primary, priority, ComputeCost.High); + this.classDeclaration = classDeclaration; + } + + public override getActiveSourceFile(): SourceFile { + return this.classDeclaration.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit); + } + + protected override async run(result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const search = new ClassBlueprintSearch(this.getProject(), this.symbols, this.classDeclaration); + if (await search.score(this.getProject(), this.context) <= 0) { + return; + } + const [project, similarClass] = await this.session.run(search, this.context, token); + if (project === undefined || similarClass === undefined) { + return; + } + const builder = new CodeSnippetBuilder(this.context, this.context.getSymbols(project), this.getActiveSourceFile()); + await builder.addDeclaration(similarClass.declaration); + result.addSnippet(builder, this.location, undefined); + } +} + +export class ClassContextProvider extends ContextProvider { + public static create(declaration: ClassDeclaration, tokenInfo: tss.TokenInfo): ContextProvider { + return declaration.members.length === 0 ? new WholeClassContextProvider(declaration, tokenInfo) : new ClassContextProvider(declaration, tokenInfo); + } + + private readonly classDeclaration: ClassDeclaration; + + constructor(classDeclaration: ClassDeclaration, _tokenInfo: tss.TokenInfo) { + super(); + this.classDeclaration = classDeclaration; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + token.throwIfCancellationRequested(); + result.addPrimary(new SuperClassRunnable(session, project, context, this.classDeclaration)); + } +} + +export class WholeClassContextProvider extends ContextProvider { + private readonly classDeclaration: ClassDeclaration; + + constructor(classDeclaration: ClassDeclaration, _tokenInfo: tss.TokenInfo) { + super(); + this.classDeclaration = classDeclaration; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + token.throwIfCancellationRequested(); + result.addPrimary(new SuperClassRunnable(session, project, context, this.classDeclaration)); + if (session.enableBlueprintSearch()) { + result.addPrimary(new SimilarClassRunnable(session, project, context, this.classDeclaration)); + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/code.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/code.ts new file mode 100644 index 00000000000000..b1a058cc24fa51 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/code.ts @@ -0,0 +1,663 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SymbolFlags, type Project, type Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import { + ModifierFlags, + getLeadingCommentRanges, + isCallSignatureDeclaration, + isClassDeclaration, + isConstructorDeclaration, + isEnumDeclaration, + isEnumMember, + isFunctionDeclaration, + isGetAccessorDeclaration, + isInterfaceDeclaration, + isMethodDeclaration, + isMethodSignatureDeclaration, + isPropertyDeclaration, + isPropertySignatureDeclaration, + isSetAccessorDeclaration, + isTypeAliasDeclaration, + SyntaxKind, + type CallSignatureDeclaration, + type ConstructorDeclaration, + type FunctionDeclaration, + type GetAccessorDeclaration, + type MethodDeclaration, + type MethodSignatureDeclaration, + type ModifierLike, + type Node, + type NodeArray, + type PropertyDeclaration, + type PropertySignatureDeclaration, + type SetAccessorDeclaration, + type SourceFile, + type TypeParameterDeclaration, +} from '@typescript/native/unstable/ast'; +import * as protocol from '../../common/serverProtocol'; +import type { RequestContext } from './contextProvider'; +import { ProgramContext, type SnippetProvider } from './types'; +import { Symbols } from './typescripts'; + +namespace Nodes { + export function getLines(node: Node, includeDocumentation: boolean, sourceFile: SourceFile = node.getSourceFile()): string[] { + const textStartPosition = node.getStart(sourceFile, includeDocumentation); + const startRange = sourceFile.getLineAndCharacterOfPosition(textStartPosition); + const lines = sourceFile.text.substring(textStartPosition, node.getEnd()).split(/\r?\n/g); + if (startRange.character > 0) { + const lineStartPosition = sourceFile.getPositionOfLineAndCharacter(startRange.line, 0); + const indent = sourceFile.text.substring(lineStartPosition, textStartPosition); + stripIndent(lines, indent); + } + trimLines(lines); + return lines; + } + + export function getDocumentation(node: Node): string[] | undefined { + const fullText = node.getFullText(); + const range = getLeadingCommentRanges(fullText, 0)?.at(-1); + if (range === undefined) { + return undefined; + } + const lines = fullText.substring(range.pos, range.end).trim().split(/\r?\n/); + trimLines(lines); + if (lines.length > 1) { + const match = lines[1].match(/^\s+/); + if (match !== null) { + stripIndent(lines, match[0], 0); + } + } + return lines; + } + + function stripIndent(lines: string[], indent: string, start: number = 1): void { + if (lines.slice(start).every(line => line.startsWith(indent))) { + for (let index = start; index < lines.length; index++) { + lines[index] = lines[index].substring(indent.length); + } + } + } + + function trimLines(lines: string[]): void { + while (lines.length > 0 && lines[0].trim().length === 0) { + lines.shift(); + } + while (lines.length > 0 && lines.at(-1)?.trim().length === 0) { + lines.pop(); + } + } +} + +abstract class AbstractEmitter { + protected readonly context: RequestContext; + + private readonly lines: string[] = []; + private indent: number = 0; + + public readonly source: string; + protected readonly additionalSources: Set = new Set(); + + constructor(context: RequestContext, source: SourceFile) { + this.context = context; + this.source = source.fileName; + } + + public abstract readonly key: string | undefined; + + public async initialize(): Promise { + } + + public abstract emit(currentSourceFile: SourceFile): Promise; + + protected async makeKey(symbols: NativeSymbol | readonly NativeSymbol[]): Promise { + const values = Array.isArray(symbols) ? symbols : [symbols]; + const keys: string[] = []; + for (const symbol of values) { + const key = await this.context.getSymbols(this.context.session.project).createKey(symbol); + if (key === undefined) { + return undefined; + } + keys.push(key); + } + return keys.length === 0 ? undefined : keys.join(';'); + } + + public getLines(): string[] { + return this.lines; + } + + public getAdditionalSources(): Set { + this.additionalSources.delete(this.source); + return this.additionalSources; + } + + protected increaseIndent(): void { + this.indent++; + } + + protected decreaseIndent(): void { + this.indent--; + } + + protected addLine(line: string): void { + this.lines.push(this.indent === 0 ? line : `${'\t'.repeat(this.indent)}${line}`); + } + + protected addLines(lines: readonly string[]): void { + for (const line of lines) { + this.addLine(line); + } + } + + protected addConstructorDeclaration(declaration: ConstructorDeclaration): void { + this.addDocumentation(declaration); + const modifiers = this.getModifiers(declaration.modifiers); + const parameters = declaration.parameters.map(parameter => parameter.getText()).join(', '); + this.addLine(`${modifiers}constructor(${parameters});`); + } + + protected addPropertyDeclaration(declaration: PropertyDeclaration | PropertySignatureDeclaration): void { + this.addLines(Nodes.getLines(declaration, this.context.includeDocumentation)); + } + + protected addMethodDeclaration(declaration: MethodDeclaration | MethodSignatureDeclaration): void { + this.addDocumentation(declaration); + const modifiers = this.getModifiers(declaration.modifiers); + const typeParameters = this.getTypeParameters(declaration.typeParameters); + const parameters = declaration.parameters.map(parameter => parameter.getText()).join(', '); + const returnType = declaration.type === undefined ? '' : `: ${declaration.type.getText()}`; + this.addLine(`${modifiers}${declaration.name.getText()}${typeParameters}(${parameters})${returnType};`); + } + + protected addCallSignatureDeclaration(declaration: CallSignatureDeclaration): void { + this.addDocumentation(declaration); + const typeParameters = this.getTypeParameters(declaration.typeParameters); + const parameters = declaration.parameters.map(parameter => parameter.getText()).join(', '); + const returnType = declaration.type === undefined ? '' : `: ${declaration.type.getText()}`; + this.addLine(`${typeParameters}(${parameters})${returnType};`); + } + + protected addGetAccessorDeclaration(declaration: GetAccessorDeclaration): void { + this.addAccessorDeclaration(declaration, 'get'); + } + + protected addSetAccessorDeclaration(declaration: SetAccessorDeclaration): void { + this.addAccessorDeclaration(declaration, 'set'); + } + + private addAccessorDeclaration(declaration: GetAccessorDeclaration | SetAccessorDeclaration, prefix: 'get' | 'set'): void { + this.addDocumentation(declaration); + const modifiers = this.getModifiers(declaration.modifiers); + const parameters = declaration.parameters.map(parameter => parameter.getText()).join(', '); + const returnType = declaration.type === undefined ? '' : `: ${declaration.type.getText()}`; + this.addLine(`${modifiers}${prefix} ${declaration.name.getText()}(${parameters})${returnType};`); + } + + protected addFunctionDeclaration(declaration: FunctionDeclaration, name?: string, ensureModifier?: string): void { + name ??= declaration.name?.getText() ?? ''; + this.addDocumentation(declaration); + const modifiers = this.getModifiers(declaration.modifiers, ensureModifier, true); + const typeParameters = this.getTypeParameters(declaration.typeParameters); + const parameters = declaration.parameters.map(parameter => parameter.getText()).join(', '); + const returnType = declaration.type === undefined ? '' : `: ${declaration.type.getText()}`; + this.addLine(`${modifiers}function ${name}${typeParameters}(${parameters})${returnType};`); + } + + protected addDocumentation(declaration: Node): void { + if (!this.context.includeDocumentation) { + return; + } + const documentation = Nodes.getDocumentation(declaration); + if (documentation !== undefined) { + this.addLines(documentation); + } + } + + protected getModifiers(modifiers: NodeArray | undefined, prefix?: string, skipFunctionModifiers: boolean = false): string { + const result: string[] = []; + if (prefix !== undefined) { + result.push(prefix); + } + for (const modifier of modifiers ?? []) { + if (skipFunctionModifiers && (modifier.kind === SyntaxKind.AsyncKeyword || modifier.kind === SyntaxKind.DeclareKeyword || modifier.kind === SyntaxKind.ExportKeyword)) { + continue; + } + result.push(modifier.getText()); + } + return result.length === 0 ? '' : `${result.join(' ')} `; + } + + protected getTypeParameters(typeParameters: NodeArray | undefined): string { + return typeParameters === undefined ? '' : `<${typeParameters.map(parameter => parameter.getText()).join(', ')}>`; + } +} + +abstract class TypeEmitter extends AbstractEmitter { + protected readonly symbols: Symbols; + protected readonly type: NativeSymbol; + protected readonly name: string; + + private readonly seen: Set = new Set(); + + constructor(context: RequestContext, symbols: Symbols, source: SourceFile, type: NativeSymbol, name: string) { + super(context, source); + this.symbols = symbols; + this.type = type; + this.name = name; + } + + protected async processMembers(members: ReadonlyMap, includePrivates: boolean = true): Promise { + for (const [name, member] of members) { + if (!this.seen.has(name)) { + this.seen.add(name); + await this.processMember(member, includePrivates); + } + } + } + + protected async processMember(member: NativeSymbol, includePrivates: boolean): Promise { + for (const declaration of await this.symbols.getDeclarations(member)) { + if (!includePrivates && this.hasModifier(declaration, ModifierFlags.Private)) { + continue; + } + if (isPropertyDeclaration(declaration) || isPropertySignatureDeclaration(declaration)) { + this.addPropertyDeclaration(declaration); + this.additionalSources.add(declaration.getSourceFile().fileName); + break; + } else if (isMethodDeclaration(declaration) || isMethodSignatureDeclaration(declaration)) { + this.addMethodDeclaration(declaration); + } else if (isGetAccessorDeclaration(declaration)) { + this.addGetAccessorDeclaration(declaration); + } else if (isSetAccessorDeclaration(declaration)) { + this.addSetAccessorDeclaration(declaration); + } else if (isCallSignatureDeclaration(declaration)) { + this.addCallSignatureDeclaration(declaration); + } else if (isConstructorDeclaration(declaration)) { + this.addConstructorDeclaration(declaration); + } else { + continue; + } + this.additionalSources.add(declaration.getSourceFile().fileName); + } + } + + protected async getTypeParametersFromSymbol(): Promise { + const declaration = (await this.symbols.getDeclarations(this.type))[0]; + if (declaration !== undefined && (isClassDeclaration(declaration) || isInterfaceDeclaration(declaration) || isTypeAliasDeclaration(declaration))) { + return this.getTypeParameters(declaration.typeParameters); + } + return ''; + } + + private hasModifier(node: Node, modifier: ModifierFlags): boolean { + return 'modifierFlags' in node && typeof node.modifierFlags === 'number' && (node.modifierFlags & modifier) !== 0; + } +} + +class ClassEmitter extends TypeEmitter { + private readonly includeSuperClasses: boolean; + private readonly includePrivates: boolean; + private superClasses: readonly NativeSymbol[] | undefined; + + public key: string | undefined; + + constructor(context: RequestContext, symbols: Symbols, source: SourceFile, type: NativeSymbol, name: string, includeSuperClasses: boolean, includePrivates: boolean) { + super(context, symbols, source, type, name); + this.includeSuperClasses = includeSuperClasses; + this.includePrivates = includePrivates; + } + + public override async initialize(): Promise { + if (this.includeSuperClasses) { + this.superClasses = (await this.symbols.getAllSuperSymbols(this.type)).filter(candidate => (candidate.flags & SymbolFlags.Class) !== 0); + this.key = await this.makeKey([this.type, ...this.superClasses]); + } else { + this.key = await this.makeKey(this.type); + } + } + + public async emit(): Promise { + this.addLine(`declare class ${this.name}${await this.getTypeParametersFromSymbol()} {`); + this.increaseIndent(); + await this.processMembers(await this.type.getMembers(), this.includePrivates); + if (this.superClasses !== undefined) { + for (let index = this.superClasses.length - 1; index >= 0; index--) { + await this.processMembers(await this.superClasses[index].getMembers(), false); + } + } + this.decreaseIndent(); + this.addLine('}'); + } +} + +class InterfaceEmitter extends TypeEmitter { + private superTypes: readonly NativeSymbol[] = []; + + public key: string | undefined; + + public override async initialize(): Promise { + this.superTypes = (await this.symbols.getAllSuperSymbols(this.type)).filter(candidate => (candidate.flags & SymbolFlags.Interface) !== 0); + this.key = await this.makeKey([this.type, ...this.superTypes]); + } + + public async emit(): Promise { + this.addLine(`interface ${this.name}${await this.getTypeParametersFromSymbol()} {`); + this.increaseIndent(); + await this.processMembers(await this.type.getMembers()); + for (let index = this.superTypes.length - 1; index >= 0; index--) { + await this.processMembers(await this.superTypes[index].getMembers()); + } + this.decreaseIndent(); + this.addLine('}'); + } +} + +class EnumEmitter extends AbstractEmitter { + private readonly type: NativeSymbol; + private readonly name: string; + private readonly declaration: Node | undefined; + + public key: string | undefined; + + constructor(context: RequestContext, source: SourceFile, type: NativeSymbol, name: string, declaration: Node | undefined) { + super(context, source); + this.type = type; + this.name = name; + this.declaration = declaration; + } + + public override async initialize(): Promise { + this.key = await this.makeKey(this.type); + } + + public async emit(): Promise { + const prefix = (this.type.flags & SymbolFlags.ConstEnum) !== 0 ? 'const ' : ''; + this.addLine(`${prefix}enum ${this.name} {`); + this.increaseIndent(); + if (this.declaration !== undefined && isEnumDeclaration(this.declaration)) { + for (let index = 0; index < this.declaration.members.length; index++) { + const member = this.declaration.members[index]; + if (!isEnumMember(member)) { + continue; + } + const lines = Nodes.getLines(member, this.context.includeDocumentation, this.declaration.getSourceFile()); + if (index < this.declaration.members.length - 1 && lines.length > 0) { + lines[lines.length - 1] += ','; + } + this.addLines(lines); + } + } + this.decreaseIndent(); + this.addLine('}'); + } +} + +class TypeLiteralEmitter extends TypeEmitter { + public key: string | undefined; + + public override async initialize(): Promise { + this.key = await this.makeKey(this.type); + } + + public async emit(): Promise { + this.addLine(`type ${this.name} = {`); + this.increaseIndent(); + await this.processMembers(await this.type.getMembers()); + this.decreaseIndent(); + this.addLine('}'); + } +} + +class FunctionEmitter extends AbstractEmitter { + private readonly symbols: Symbols; + private readonly func: NativeSymbol; + private readonly name: string; + + public readonly key: string | undefined = undefined; + + constructor(context: RequestContext, symbols: Symbols, source: SourceFile, func: NativeSymbol, name?: string) { + super(context, source); + this.symbols = symbols; + this.func = func; + this.name = name ?? func.name; + } + + public async emit(currentSourceFile: SourceFile): Promise { + for (const declaration of await this.symbols.getDeclarations(this.func)) { + if (isFunctionDeclaration(declaration) && declaration.getSourceFile().path !== currentSourceFile.path) { + this.addFunctionDeclaration(declaration, this.name, 'declare'); + this.additionalSources.add(declaration.getSourceFile().fileName); + } + } + } +} + +class ModuleEmitter extends AbstractEmitter { + private readonly symbols: Symbols; + private readonly module: NativeSymbol; + private readonly name: string; + + public readonly key: string | undefined = undefined; + + constructor(context: RequestContext, symbols: Symbols, source: SourceFile, module: NativeSymbol, name?: string) { + super(context, source); + this.symbols = symbols; + this.module = module; + this.name = name ?? module.name; + } + + public async emit(currentSourceFile: SourceFile): Promise { + this.addLine(`declare namespace ${this.name} {`); + this.increaseIndent(); + await this.addExports(await this.module.getExports(), currentSourceFile); + this.decreaseIndent(); + this.addLine('}'); + } + + private async addExports(members: ReadonlyMap, currentSourceFile: SourceFile): Promise { + for (const member of members.values()) { + if ((member.flags & SymbolFlags.Function) === 0) { + continue; + } + for (const declaration of await this.symbols.getDeclarations(member)) { + if (isFunctionDeclaration(declaration) && declaration.getSourceFile().path !== currentSourceFile.path) { + this.addFunctionDeclaration(declaration); + this.additionalSources.add(declaration.getSourceFile().fileName); + } + } + } + } +} + +export class CodeSnippetBuilder extends ProgramContext implements SnippetProvider { + private readonly context: RequestContext; + private readonly symbols: Symbols; + private readonly currentSourceFile: SourceFile; + private readonly lines: string[] = []; + private readonly additionalSources: Set = new Set(); + private source: string | undefined; + private indent: number = 0; + + constructor(context: RequestContext, symbols: Symbols, currentSourceFile: SourceFile) { + super(); + this.context = context; + this.symbols = symbols; + this.currentSourceFile = currentSourceFile; + } + + public isEmpty(): boolean { + return this.lines.length === 0 || this.source === undefined; + } + + public snippet(key: string | undefined): protocol.CodeSnippet { + if (this.source === undefined) { + throw new Error('No source'); + } + this.additionalSources.delete(this.source); + return protocol.CodeSnippet.create(key, this.source, this.additionalSources.size === 0 ? undefined : [...this.additionalSources], this.lines.join('\n')); + } + + public async addDeclaration(declaration: Node): Promise { + const sourceFile = declaration.getSourceFile(); + if (!await this.canUseSourceFile(sourceFile)) { + return; + } + this.addLines(Nodes.getLines(declaration, this.context.includeDocumentation, sourceFile)); + this.addSource(sourceFile.fileName); + } + + public addLines(lines: readonly string[]): void { + this.lines.push(...(this.indent === 0 ? lines : lines.map(line => `${'\t'.repeat(this.indent)}${line}`))); + } + + public async addClassSymbol(clazz: NativeSymbol, name: string, includeSuperClasses: boolean = true, includePrivates: boolean = false): Promise { + if ((clazz.flags & SymbolFlags.Class) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(clazz, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new ClassEmitter(this.context, this.symbols, info.primary, clazz, name, includeSuperClasses, includePrivates)); + } + } + + public async addTypeLiteralSymbol(type: NativeSymbol, name: string): Promise { + if ((type.flags & SymbolFlags.TypeLiteral) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(type, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new TypeLiteralEmitter(this.context, this.symbols, info.primary, type, name)); + } + } + + public async addInterfaceSymbol(iface: NativeSymbol, name: string): Promise { + if ((iface.flags & SymbolFlags.Interface) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(iface, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new InterfaceEmitter(this.context, this.symbols, info.primary, iface, name)); + } + } + + public async addTypeAliasSymbol(_symbol: NativeSymbol, _name: string): Promise { + } + + public async addEnumSymbol(enm: NativeSymbol, name: string): Promise { + if ((enm.flags & (SymbolFlags.RegularEnum | SymbolFlags.ConstEnum)) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(enm, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new EnumEmitter(this.context, info.primary, enm, name, info.declarations.find(isEnumDeclaration))); + } + } + + public async addFunctionSymbol(func: NativeSymbol, name?: string): Promise { + if ((func.flags & SymbolFlags.Function) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(func, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new FunctionEmitter(this.context, this.symbols, info.primary, func, name)); + } + } + + public async addModuleSymbol(module: NativeSymbol, name?: string): Promise { + if ((module.flags & SymbolFlags.ValueModule) === 0) { + return; + } + const info = await this.symbols.getSymbolInfo(module, this.currentSourceFile); + if (info !== undefined) { + await this.addEmitter(new ModuleEmitter(this.context, this.symbols, info.primary, module, name)); + } + } + + public async addTypeSymbol(type: NativeSymbol, name?: string): Promise { + if (name === undefined && this.isInternal(type)) { + return; + } + const symbolName = name ?? type.name; + if ((type.flags & SymbolFlags.Class) !== 0) { + await this.addClassSymbol(type, symbolName); + } else if ((type.flags & SymbolFlags.Interface) !== 0) { + await this.addInterfaceSymbol(type, symbolName); + } else if ((type.flags & SymbolFlags.TypeAlias) !== 0) { + await this.addTypeAliasSymbol(type, symbolName); + } else if ((type.flags & (SymbolFlags.RegularEnum | SymbolFlags.ConstEnum)) !== 0) { + await this.addEnumSymbol(type, symbolName); + } else if ((type.flags & SymbolFlags.Function) !== 0) { + await this.addFunctionSymbol(type, symbolName); + } else if ((type.flags & SymbolFlags.ValueModule) !== 0) { + await this.addModuleSymbol(type, symbolName); + } else if ((type.flags & SymbolFlags.TypeLiteral) !== 0) { + await this.addTypeLiteralSymbol(type, symbolName); + } + } + + protected override getProject(): Project { + return this.symbols.getProject(); + } + + protected override getSymbols(): Symbols { + return this.symbols; + } + + private async addEmitter(emitter: AbstractEmitter): Promise { + await emitter.initialize(); + let lines: string[] | undefined; + let source: string | undefined; + let additionalSources: Set | undefined; + if (emitter.key !== undefined) { + const cached = this.context.session.getCachedCode(emitter.key); + if (cached !== undefined) { + lines = cached.value; + source = cached.uri; + additionalSources = cached.additionalUris; + } + } + if (lines === undefined || source === undefined) { + await emitter.emit(this.currentSourceFile); + lines = emitter.getLines(); + source = emitter.source; + additionalSources = emitter.getAdditionalSources(); + if (emitter.key !== undefined) { + this.context.session.cacheCode(emitter.key, { value: lines, uri: source, additionalUris: additionalSources }); + } + } + this.addLines(lines); + this.addSource(source); + this.addAdditionalSource(additionalSources); + } + + private async canUseSourceFile(sourceFile: SourceFile): Promise { + if (sourceFile.path === this.currentSourceFile.path) { + return false; + } + const metadata = await this.symbols.getProject().program.getSourceFileMetadataByPath(sourceFile.path); + return !metadata?.isDefaultLibrary && !metadata?.isFromExternalLibrary; + } + + private isInternal(symbol: NativeSymbol): boolean { + return symbol.name === '__type' || symbol.name === '__class' || symbol.name === '__object'; + } + + private addSource(source: string): void { + if (this.source === undefined) { + this.source = source; + } else if (this.source !== source) { + this.additionalSources.add(source); + } + } + + private addAdditionalSource(sources: Set | undefined): void { + if (sources !== undefined) { + for (const source of sources) { + this.additionalSources.add(source); + } + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/contextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/contextProvider.ts new file mode 100644 index 00000000000000..4c3e04a4b95dfa --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/contextProvider.ts @@ -0,0 +1,752 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project, Symbol as NativeSymbol, Type } from '@typescript/native/unstable/async'; +import { + isIntersectionTypeNode, + isTypeAliasDeclaration, + isTypeLiteralNode, + isTypeReferenceNode, + isUnionTypeNode, + type FunctionLikeDeclaration, + type Node, + type NodeArray, + type SourceFile, + type TypeAliasDeclaration, + type TypeNode, +} from '@typescript/native/unstable/ast'; +import { CodeSnippetBuilder } from './code'; +import * as protocol from '../../common/serverProtocol'; +import { type CodeCacheItem, type EmitterContext, ProgramContext, RecoverableError, type SnippetProvider } from './types'; +import tss, { type CancellationTokenWithTimer, Symbols, Types } from './typescripts'; + +export class RequestContext { + private readonly symbols: Map = new Map(); + private readonly clientSideContextItems: Map = new Map(); + + public readonly neighborFiles: readonly string[]; + public readonly clientSideRunnableResults: Map; + public readonly session: ComputeContextSession; + public readonly includeDocumentation: boolean; + + constructor(session: ComputeContextSession, neighborFiles: readonly string[], clientSideRunnableResults: Map, includeDocumentation: boolean) { + this.session = session; + this.neighborFiles = neighborFiles; + this.clientSideRunnableResults = clientSideRunnableResults; + this.includeDocumentation = includeDocumentation; + for (const runnableResult of clientSideRunnableResults.values()) { + for (const item of runnableResult.items) { + this.clientSideContextItems.set(item.key, item); + } + } + } + + public getSymbols(project: Project): Symbols { + let result = this.symbols.get(project); + if (result === undefined) { + result = new Symbols(project, this.session.token); + this.symbols.set(project, result); + } + return result; + } + + public async getPreferredNeighborFiles(project: Project): Promise { + const result: SourceFile[] = []; + for (const file of this.neighborFiles) { + const sourceFile = await project.program.getSourceFile(file); + if (sourceFile !== undefined) { + result.push(sourceFile); + } + } + return result; + } + + public createContextItemReferenceIfManaged(key: protocol.ContextItemKey): protocol.ContextItemReference | undefined { + const cachedItem = this.clientSideContextItems.get(key); + return cachedItem === undefined ? undefined : protocol.ContextItemReference.create(cachedItem.key); + } + + public clientHasContextItem(key: protocol.ContextItemKey): boolean { + return this.clientSideContextItems.has(key); + } +} + +export abstract class Search extends ProgramContext { + protected readonly project: Project; + protected readonly symbols: Symbols; + + constructor(project: Project, symbols: Symbols) { + super(); + if (project !== symbols.getProject()) { + throw new Error('Project and symbols project must match'); + } + this.project = project; + this.symbols = symbols; + } + + public getSymbols(): Symbols { + return this.symbols; + } + + protected getProject(): Project { + return this.project; + } + + public abstract with(project: Project, symbols: Symbols): Search; + public abstract score(project: Project, context: RequestContext): Promise; + public abstract run(context: RequestContext, token: CancellationTokenWithTimer): Promise; +} + +export class ComputeContextSession implements EmitterContext { + public readonly project: Project; + public readonly token: CancellationTokenWithTimer; + + private readonly codeCache: Map = new Map(); + + constructor(project: Project, token: CancellationTokenWithTimer) { + this.project = project; + this.token = token; + } + + public async run(search: Search, context: RequestContext, token: CancellationTokenWithTimer): Promise<[Project | undefined, R | undefined]> { + const symbols = context.getSymbols(this.project); + const projectSearch = search.with(this.project, symbols); + if (await projectSearch.score(this.project, context) <= 0) { + return [undefined, undefined]; + } + const result = await projectSearch.run(context, token); + return result === undefined ? [undefined, undefined] : [this.project, result]; + } + + public getCachedCode(key: string): CodeCacheItem | undefined { + return this.codeCache.get(key); + } + + public cacheCode(key: string, code: CodeCacheItem): void { + this.codeCache.set(key, code); + } + + public enableBlueprintSearch(): boolean { + return false; + } +} + +export interface RunnableResultContext { + createContextItemReference(key: protocol.ContextItemKey): protocol.ContextItemReference | undefined; + manageContextItem(item: protocol.FullContextItem): protocol.ContextItem; +} + +export enum SnippetLocation { + Primary, + Secondary, +} + +export class RunnableResult { + private readonly id: string; + private readonly runnableResultContext: RunnableResultContext; + private readonly primaryBudget: CharacterBudget; + private readonly secondaryBudget: CharacterBudget; + private state: protocol.ContextRunnableState; + private speculativeKind: protocol.SpeculativeKind; + private cache: protocol.CacheInfo | undefined; + + public readonly priority: number; + public readonly items: Map; + public debugPath: string | undefined; + + constructor(id: protocol.ContextRunnableResultId, priority: number, runnableResultContext: RunnableResultContext, primaryBudget: CharacterBudget, secondaryBudget: CharacterBudget, speculativeKind: protocol.SpeculativeKind, cache?: protocol.CacheInfo) { + this.id = id; + this.priority = priority; + this.runnableResultContext = runnableResultContext; + this.primaryBudget = primaryBudget; + this.secondaryBudget = secondaryBudget; + this.state = protocol.ContextRunnableState.Created; + this.speculativeKind = speculativeKind; + this.cache = cache; + this.items = new Map(); + } + + public isPrimaryBudgetExhausted(): boolean { + if (this.primaryBudget.isExhausted()) { + this.state = protocol.ContextRunnableState.IsFull; + return true; + } + return false; + } + + public isSecondaryBudgetExhausted(): boolean { + return this.secondaryBudget.isExhausted(); + } + + public done(): void { + if (this.state === protocol.ContextRunnableState.Created || this.state === protocol.ContextRunnableState.InProgress) { + this.state = protocol.ContextRunnableState.Finished; + } + } + + public setCacheInfo(cache: protocol.CacheInfo): void { + this.cache = cache; + } + + public addFromKnownItems(key: string): boolean { + this.state = protocol.ContextRunnableState.InProgress; + const reference = this.runnableResultContext.createContextItemReference(key); + if (reference === undefined) { + return false; + } + this.items.set(key, reference); + return true; + } + + public addTrait(traitKind: protocol.TraitKind, name: string, value: string, key: string): void { + this.state = protocol.ContextRunnableState.InProgress; + const trait = protocol.Trait.create(traitKind, name, value); + this.items.set(key ?? crypto.randomUUID(), this.runnableResultContext.manageContextItem(trait)); + this.primaryBudget.spent(protocol.Trait.sizeInChars(trait)); + } + + public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined): void; + public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: false): void; + public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: true): boolean; + public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: boolean): boolean; + public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: boolean = false): boolean { + const budget = location === SnippetLocation.Primary ? this.primaryBudget : this.secondaryBudget; + if (code.isEmpty()) { + return true; + } + const snippet = code.snippet(key); + const size = protocol.CodeSnippet.sizeInChars(snippet); + if (ifRoom && !budget.hasRoom(size)) { + this.state = protocol.ContextRunnableState.IsFull; + return false; + } + this.state = protocol.ContextRunnableState.InProgress; + budget.spent(size); + this.items.set(key ?? crypto.randomUUID(), this.runnableResultContext.manageContextItem(snippet)); + return true; + } + + public toJson(): protocol.ContextRunnableResult { + return { + kind: protocol.ContextRunnableResultKind.ComputedResult, + id: this.id, + state: this.state, + priority: this.priority, + items: Array.from(this.items.values()), + cache: this.cache, + speculativeKind: this.speculativeKind, + debugPath: this.debugPath, + }; + } +} + +class RunnableResultReference { + private readonly cached: protocol.CachedContextRunnableResult; + + constructor(cached: protocol.CachedContextRunnableResult) { + this.cached = cached; + } + + public get items(): protocol.ContextItem[] { + return this.cached.items.map(item => protocol.ContextItemReference.create(item.key)); + } + + public toJson(): protocol.ContextRunnableResultReference { + return { kind: protocol.ContextRunnableResultKind.Reference, id: this.cached.id }; + } +} + +export class ContextResult implements RunnableResultContext { + public readonly primaryBudget: CharacterBudget; + public readonly secondaryBudget: CharacterBudget; + public readonly context: RequestContext; + + private state: protocol.ContextRequestResultState = protocol.ContextRequestResultState.Created; + private path: number[] | undefined; + private timings: protocol.Timings | undefined; + private timedOut: boolean = false; + private readonly errors: protocol.ErrorData[] = []; + private readonly runnableResults: (RunnableResult | RunnableResultReference)[] = []; + private readonly contextItems: Map = new Map(); + + constructor(primaryBudget: CharacterBudget, secondaryBudget: CharacterBudget, context: RequestContext) { + this.primaryBudget = primaryBudget; + this.secondaryBudget = secondaryBudget; + this.context = context; + } + + public getSession(): ComputeContextSession { + return this.context.session; + } + + public addPath(path: number[]): void { + this.path = path; + } + + public addErrorData(error: RecoverableError): void { + this.errors.push(protocol.ErrorData.create(error.code, error.message)); + } + + public addTimings(totalTime: number, computeTime: number): void { + this.timings = protocol.Timings.create(totalTime, computeTime); + } + + public setTimedOut(timedOut: boolean): void { + this.timedOut = timedOut; + } + + public createRunnableResult(id: protocol.ContextRunnableResultId, priority: number, speculativeKind: protocol.SpeculativeKind, cache?: protocol.CacheInfo): RunnableResult { + this.state = protocol.ContextRequestResultState.InProgress; + const result = new RunnableResult(id, priority, this, this.primaryBudget, this.secondaryBudget, speculativeKind, cache); + this.runnableResults.push(result); + return result; + } + + public addRunnableResultReference(cached: protocol.CachedContextRunnableResult): void { + this.state = protocol.ContextRequestResultState.InProgress; + this.runnableResults.push(new RunnableResultReference(cached)); + } + + public createContextItemReference(key: protocol.ContextItemKey): protocol.ContextItemReference | undefined { + return this.context.createContextItemReferenceIfManaged(key) + ?? (this.contextItems.has(key) ? protocol.ContextItemReference.create(key) : undefined); + } + + public manageContextItem(item: protocol.FullContextItem): protocol.ContextItem { + if (!protocol.ContextItem.hasKey(item)) { + return item; + } + if (this.context.clientHasContextItem(item.key) || this.contextItems.has(item.key)) { + return protocol.ContextItemReference.create(item.key); + } + this.contextItems.set(item.key, item); + return protocol.ContextItemReference.create(item.key); + } + + public done(): void { + this.state = protocol.ContextRequestResultState.Finished; + } + + public toJson(): protocol.ComputeContextResponse.OK { + return { + state: this.state, + path: this.path, + timings: this.timings, + errors: this.errors, + timedOut: this.timedOut, + exhausted: this.primaryBudget.isExhausted(), + runnableResults: this.runnableResults.map(result => result.toJson()), + contextItems: Array.from(this.contextItems.values()), + }; + } +} + +export enum ComputeCost { + Low = 1, + Medium = 2, + High = 3, +} + +export namespace CacheScopes { + export function fromDeclaration(declaration: FunctionLikeDeclaration): protocol.CacheScope | undefined { + return declaration.body === undefined ? undefined : createWithinCacheScope(declaration.body, declaration.getSourceFile()); + } + + export function createWithinCacheScope(node: Node | NodeArray, sourceFile?: SourceFile): protocol.CacheScope { + return { kind: protocol.CacheScopeKind.WithinRange, range: createRange(node, sourceFile) }; + } + + export function createOutsideCacheScope(nodes: Iterable, sourceFile: SourceFile): protocol.CacheScope { + const ranges = Array.from(nodes, node => createRange(node, sourceFile)); + ranges.sort((first, second) => first.start.line - second.start.line || first.start.character - second.start.character); + return { kind: protocol.CacheScopeKind.OutsideRange, ranges }; + } + + export function createRange(node: Node | NodeArray, sourceFile?: SourceFile): protocol.Range { + let startOffset: number; + let endOffset: number; + if (Array.isArray(node)) { + startOffset = node.pos; + endOffset = node.end; + } else { + const syntaxNode = node as Node; + sourceFile ??= syntaxNode.getSourceFile(); + startOffset = syntaxNode.getStart(sourceFile); + endOffset = syntaxNode.getEnd(); + } + if (sourceFile === undefined) { + throw new Error('No source file for cache range'); + } + return { + start: sourceFile.getLineAndCharacterOfPosition(startOffset), + end: sourceFile.getLineAndCharacterOfPosition(endOffset), + }; + } +} + +export interface ContextRunnable { + readonly id: protocol.ContextRunnableResultId; + readonly priority: number; + readonly cost: ComputeCost; + initialize(result: ContextResult): void; + compute(token: CancellationTokenWithTimer): Promise; +} + +class CacheBasedContextRunnable implements ContextRunnable { + private readonly cached: protocol.CachedContextRunnableResult; + private tokenBudget: CharacterBudget | undefined; + + public readonly id: protocol.ContextRunnableResultId; + public readonly priority: number; + public readonly cost: ComputeCost; + + constructor(cached: protocol.CachedContextRunnableResult, priority: number, cost: ComputeCost) { + this.cached = cached; + this.id = cached.id; + this.priority = priority; + this.cost = cost; + } + + public initialize(result: ContextResult): void { + this.tokenBudget = result.primaryBudget; + result.addRunnableResultReference(this.cached); + } + + public async compute(): Promise { + for (const item of this.cached.items) { + this.tokenBudget?.spent(item.sizeInChars ?? 0); + } + } +} + +export type SymbolData = { symbol: NativeSymbol; name?: string }; + +enum SymbolEmitDataKind { + symbol = 'symbol', + typeAlias = 'typeAlias', +} + +type SymbolEmitData = { kind: SymbolEmitDataKind.symbol; symbol: NativeSymbol; name?: string }; +type TypeAliasEmitData = { kind: SymbolEmitDataKind.typeAlias; node: TypeAliasDeclaration }; +type EmitData = SymbolEmitData | TypeAliasEmitData; + +export abstract class AbstractContextRunnable implements ContextRunnable { + public readonly session: ComputeContextSession; + public readonly symbols: Symbols; + public readonly id: protocol.ContextRunnableResultId; + protected readonly location: SnippetLocation; + public readonly priority: number; + public readonly cost: ComputeCost; + + protected readonly project: Project; + protected readonly context: RequestContext; + private result: RunnableResult | undefined; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, id: protocol.ContextRunnableResultId, location: SnippetLocation, priority: number, cost: ComputeCost) { + this.session = session; + this.project = project; + this.context = context; + this.symbols = context.getSymbols(project); + this.id = id; + this.location = location; + this.priority = priority; + this.cost = cost; + } + + public initialize(result: ContextResult): void { + if (this.result !== undefined) { + throw new Error('Runnable already initialized'); + } + this.result = this.createRunnableResult(result); + } + + public useCachedResult(cached: protocol.CachedContextRunnableResult): boolean { + const cacheInfo = cached.cache; + if (cacheInfo?.emitMode !== protocol.EmitMode.ClientBased) { + return false; + } + if (cached.state === protocol.ContextRunnableState.Finished) { + return true; + } + if (cached.state !== protocol.ContextRunnableState.IsFull) { + return false; + } + const kind = cacheInfo.scope.kind; + return kind === protocol.CacheScopeKind.WithinRange || kind === protocol.CacheScopeKind.NeighborFiles || kind === protocol.CacheScopeKind.File; + } + + public async compute(token: CancellationTokenWithTimer): Promise { + if (this.result === undefined) { + throw new Error('Runnable not initialized'); + } + token.throwIfCancellationRequested(); + if (!this.result.isPrimaryBudgetExhausted()) { + await this.run(this.result, token); + this.result.done(); + } + } + + public abstract getActiveSourceFile(): SourceFile; + protected abstract createRunnableResult(result: ContextResult): RunnableResult; + protected abstract run(result: RunnableResult, token: CancellationTokenWithTimer): Promise; + + protected getProject(): Project { + return this.project; + } + + protected createCacheScope(node: Node | NodeArray, sourceFile?: SourceFile): protocol.CacheScope { + return CacheScopes.createWithinCacheScope(node, sourceFile); + } + + protected async handleSymbol(symbol: NativeSymbol, name?: string, ifRoom: boolean = false): Promise { + if (this.result === undefined) { + return true; + } + const emitData = await this.getEmitDataForSymbol(symbol, name); + for (const item of emitData) { + if (item.kind === SymbolEmitDataKind.typeAlias) { + if (await this.skipNode(item.node)) { + continue; + } + const key = await this.symbols.createKey(item.node); + if (key !== undefined && this.result.addFromKnownItems(key)) { + continue; + } + const builder = new CodeSnippetBuilder(this.context, this.symbols, this.getActiveSourceFile()); + await builder.addDeclaration(item.node); + if (!builder.isEmpty() && !this.result.addSnippet(builder, this.location, key, ifRoom)) { + return false; + } + } else { + if (Symbols.isTypeParameter(item.symbol) || await this.skipSymbolBasedOnDeclaration(item.symbol)) { + continue; + } + const key = await this.symbols.createKey(item.symbol); + if (key !== undefined && this.result.addFromKnownItems(key)) { + continue; + } + const builder = new CodeSnippetBuilder(this.context, this.symbols, this.getActiveSourceFile()); + await builder.addTypeSymbol(item.symbol, item.name); + if (!builder.isEmpty() && !this.result.addSnippet(builder, this.location, key, ifRoom)) { + return false; + } + } + } + return true; + } + + protected async skipNode(node: Node): Promise { + return this.skipSourceFile(node.getSourceFile()); + } + + protected async skipSourceFile(sourceFile: SourceFile): Promise { + if (this.getActiveSourceFile().path === sourceFile.path) { + return true; + } + const metadata = await this.project.program.getSourceFileMetadataByPath(sourceFile.path); + return metadata?.isDefaultLibrary === true || metadata?.isFromExternalLibrary === true; + } + + protected async skipSymbolBasedOnDeclaration(symbol: NativeSymbol): Promise { + for (const declaration of await this.symbols.getDeclarations(symbol)) { + if (await this.skipSourceFile(declaration.getSourceFile())) { + return true; + } + } + return false; + } + + protected async getSymbolsForTypeNode(node: TypeNode): Promise { + const result: SymbolData[] = []; + await this.doGetSymbolsForTypeNode(result, node); + return result; + } + + protected async getSymbolsToEmitForType(type: Type): Promise { + return (await this.symbols.getTypeSymbols(type)).map(symbol => ({ symbol, name: symbol.name })); + } + + private async doGetSymbolsForTypeNode(result: SymbolData[], node: TypeNode): Promise { + if (isTypeReferenceNode(node)) { + const symbol = await this.symbols.getLeafSymbolAtLocation(node.typeName); + if (symbol !== undefined) { + result.push({ symbol, name: node.typeName.getText() }); + } + } else if (isUnionTypeNode(node) || isIntersectionTypeNode(node)) { + for (const type of node.types) { + await this.doGetSymbolsForTypeNode(result, type); + } + } else if (isTypeLiteralNode(node)) { + const symbol = await this.symbols.getLeafSymbolAtLocation(node); + if (symbol !== undefined) { + result.push({ symbol, name: symbol.name }); + } + } + } + + private async getEmitDataForSymbol(symbol: NativeSymbol, name?: string): Promise { + const result: EmitData[] = []; + await this.doGetEmitDataForSymbol(result, new Set(), 0, symbol, name); + return result; + } + + private async doGetEmitDataForSymbol(result: EmitData[], seen: Set, level: number, initialSymbol: NativeSymbol, name?: string): Promise { + const symbol = Symbols.isAlias(initialSymbol) ? await this.symbols.getLeafSymbol(initialSymbol) : initialSymbol; + if (seen.has(symbol.id) || level > 2) { + return; + } + seen.add(symbol.id); + if (!Symbols.isTypeAlias(symbol)) { + result.push({ kind: SymbolEmitDataKind.symbol, symbol, name }); + return; + } + + const declaration = (await this.symbols.getDeclarations(symbol)).find(isTypeAliasDeclaration); + if (declaration === undefined) { + return; + } + name ??= declaration.name.getText(); + const type = declaration.type; + if (isTypeLiteralNode(type)) { + let typeSymbol = await this.symbols.getSymbolAtLocation(type); + if (typeSymbol === undefined) { + const resolvedType = await this.project.checker.getTypeFromTypeNode(type); + typeSymbol = resolvedType === undefined ? undefined : await resolvedType.getSymbol(); + } + if (typeSymbol !== undefined && !seen.has(typeSymbol.id)) { + result.push({ kind: SymbolEmitDataKind.symbol, symbol: typeSymbol, name }); + } + } else if (isTypeReferenceNode(type)) { + const typeSymbol = await this.symbols.getSymbolAtLocation(type.typeName); + if (typeSymbol !== undefined) { + await this.doGetEmitDataForSymbol(result, seen, level + 1, typeSymbol, name); + } + } else if (isUnionTypeNode(type) || isIntersectionTypeNode(type)) { + result.push({ kind: SymbolEmitDataKind.typeAlias, node: declaration }); + if (level < 2) { + for (const item of type.types) { + for (const data of await this.getSymbolsForTypeNode(item)) { + await this.doGetEmitDataForSymbol(result, seen, level + 1, data.symbol, data.name); + } + } + } + } + } +} + +export class ContextRunnableCollector { + private readonly cachedRunnableResults: Map; + + public readonly primary: ContextRunnable[] = []; + public readonly secondary: ContextRunnable[] = []; + public readonly tertiary: ContextRunnable[] = []; + + constructor(cachedRunnableResults: Map) { + this.cachedRunnableResults = cachedRunnableResults; + } + + public addPrimary(runnable: AbstractContextRunnable): void { + this.primary.push(this.useCachedRunnableIfPossible(runnable)); + } + + public addSecondary(runnable: AbstractContextRunnable): void { + this.secondary.push(this.useCachedRunnableIfPossible(runnable)); + } + + public addTertiary(runnable: AbstractContextRunnable): void { + this.tertiary.push(this.useCachedRunnableIfPossible(runnable)); + } + + public *entries(): IterableIterator { + yield* this.primary; + yield* this.secondary; + yield* this.tertiary; + } + + public getPrimaryRunnables(): ContextRunnable[] { + return this.sort(this.primary); + } + + public getSecondaryRunnables(): ContextRunnable[] { + return this.sort(this.secondary); + } + + public getTertiaryRunnables(): ContextRunnable[] { + return this.sort(this.tertiary); + } + + private sort(runnables: ContextRunnable[]): ContextRunnable[] { + return runnables.sort((first, second) => first.cost - second.cost || second.priority - first.priority); + } + + private useCachedRunnableIfPossible(runnable: AbstractContextRunnable): ContextRunnable { + const cached = this.cachedRunnableResults.get(runnable.id); + return cached !== undefined && runnable.useCachedResult(cached) + ? new CacheBasedContextRunnable(cached, runnable.priority, runnable.cost) + : runnable; + } +} + +export abstract class ContextProvider { + public isCallableProvider?: boolean; + + public abstract provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise; +} + +export interface ProviderComputeContext { + isFirstCallableProvider(contextProvider: ContextProvider): boolean; +} + +export type ContextProviderFactory = (node: Node, tokenInfo: tss.TokenInfo, context: ProviderComputeContext) => ContextProvider | undefined; + +export class TokenBudgetExhaustedError extends Error { + constructor() { + super('Budget exhausted'); + } +} + +export class CharacterBudget { + private charBudget: number; + private readonly lowWaterMark: number; + private itemRejected: boolean = false; + + constructor(budget: number, lowWaterMark: number = 256) { + this.charBudget = budget; + this.lowWaterMark = lowWaterMark; + } + + public spent(chars: number): void { + this.charBudget -= chars; + } + + public hasRoom(chars: number): boolean { + const result = this.charBudget - this.lowWaterMark >= chars; + if (!result) { + this.itemRejected = true; + } + return result; + } + + public isExhausted(): boolean { + return this.charBudget <= 0; + } + + public wasItemRejected(): boolean { + return this.itemRejected; + } + + public throwIfExhausted(): void { + if (this.isExhausted()) { + throw new TokenBudgetExhaustedError(); + } + } + + public spentAndThrowIfExhausted(chars: number): void { + this.spent(chars); + this.throwIfExhausted(); + } +} + +export { Types }; diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/functionContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/functionContextProvider.ts new file mode 100644 index 00000000000000..c866dc032c8a0b --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/functionContextProvider.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project, Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import type { ArrowFunction, FunctionDeclaration, FunctionExpression } from '@typescript/native/unstable/ast'; +import { FunctionLikeContextProvider } from './baseContextProviders'; +import type { ComputeContextSession, ContextRunnableCollector, ProviderComputeContext, RequestContext } from './contextProvider'; +import type tss from './typescripts'; +import type { CancellationTokenWithTimer } from './typescripts'; + +export class FunctionContextProvider extends FunctionLikeContextProvider { + protected readonly functionDeclaration: FunctionDeclaration | ArrowFunction | FunctionExpression; + + constructor(functionDeclaration: FunctionDeclaration | ArrowFunction | FunctionExpression, tokenInfo: tss.TokenInfo, computeContext: ProviderComputeContext) { + super(functionDeclaration, tokenInfo, computeContext); + this.functionDeclaration = functionDeclaration; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + await super.provide(result, session, project, context, token); + } + + protected override async getTypeExcludes(): Promise> { + return new Set(); + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/methodContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/methodContextProvider.ts new file mode 100644 index 00000000000000..4e05d61be88f66 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/methodContextProvider.ts @@ -0,0 +1,481 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SignatureKind, type Project, type Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import { + escapeLeadingUnderscores, + InternalSymbolName, + ModifierFlags, + isClassDeclaration, + isConstructorDeclaration, + isExpressionWithTypeArguments, + isGetAccessorDeclaration, + isInterfaceDeclaration, + isMethodDeclaration, + isMethodSignatureDeclaration, + isPropertyDeclaration, + isPropertySignatureDeclaration, + isSetAccessorDeclaration, + isTypeReferenceNode, + type ClassDeclaration, + type ConstructorDeclaration, + type GetAccessorDeclaration, + type InterfaceDeclaration, + type MethodDeclaration, + type Node, + type SetAccessorDeclaration, + type SourceFile, + type __String, +} from '@typescript/native/unstable/ast'; +import { FunctionLikeContextProvider, FunctionLikeContextRunnable } from './baseContextProviders'; +import { CodeSnippetBuilder } from './code'; +import { + AbstractContextRunnable, + ComputeCost, + Search, + SnippetLocation, + type ComputeContextSession, + type ContextResult, + type ContextRunnableCollector, + type ProviderComputeContext, + type RequestContext, + type RunnableResult, +} from './contextProvider'; +import * as protocol from '../../common/serverProtocol'; +import { type CancellationTokenWithTimer, Symbols, type TokenInfo } from './typescripts'; + +abstract class ClassPropertyBlueprintSearch extends Search { + protected declaration: T; + + constructor(project: Project, symbols: Symbols, declaration: T) { + super(project, symbols); + this.declaration = declaration; + } + + public isSame(other: T): boolean { + return this.declaration === other || (this.declaration.getSourceFile().path === other.getSourceFile().path && this.declaration.pos === other.pos); + } + + public override async score(project: Project, context: RequestContext): Promise { + if (await project.program.getSourceFile(this.declaration.getSourceFile().fileName) === undefined) { + return 0; + } + if (context.neighborFiles.length === 0) { + return 1; + } + let result = Math.pow(10, context.neighborFiles.length.toString().length); + for (const file of context.neighborFiles) { + if (await project.program.getSourceFile(file) !== undefined) { + result++; + } + } + return result; + } + + protected async findClassWithMember(startSymbols: readonly NativeSymbol[], memberName: __String, token: CancellationTokenWithTimer): Promise { + const queue = [...startSymbols]; + const seen = new Set(queue.map(symbol => symbol.id)); + while (queue.length > 0) { + token.throwIfCancellationRequested(); + const current = queue.shift(); + if (current === undefined) { + break; + } + for (const candidate of await this.getDirectSubTypes(current, token)) { + if (seen.has(candidate.id)) { + continue; + } + seen.add(candidate.id); + queue.push(candidate); + if (!Symbols.isClass(candidate)) { + continue; + } + const member = (await candidate.getMembers()).get(memberName); + if (member === undefined) { + continue; + } + for (const declaration of await this.symbols.getDeclarations(member)) { + if (declaration.kind !== this.declaration.kind || (!isMethodDeclaration(declaration) && !isConstructorDeclaration(declaration))) { + continue; + } + const parent = declaration.parent; + if (isClassDeclaration(parent) && !this.isCurrentClass(parent)) { + return parent; + } + } + } + } + return undefined; + } + + private async getDirectSubTypes(symbol: NativeSymbol, token: CancellationTokenWithTimer): Promise { + const result: NativeSymbol[] = []; + const seen = new Set(); + for (const declaration of await this.symbols.getDeclarations(symbol)) { + const name = (isClassDeclaration(declaration) || isInterfaceDeclaration(declaration)) ? declaration.name : undefined; + if (name === undefined) { + continue; + } + for (const entry of await this.project.checker.getReferencedSymbolsForNode(name, name.getStart())) { + for (const reference of entry.references) { + token.throwIfCancellationRequested(); + const node = await reference.resolve(this.project); + const subtypeDeclaration = node === undefined ? undefined : this.getContainingHeritageDeclaration(node); + if (subtypeDeclaration === undefined) { + continue; + } + const subtype = await this.symbols.getLeafSymbolAtLocation(subtypeDeclaration.name ?? subtypeDeclaration); + if (subtype !== undefined && !seen.has(subtype.id)) { + seen.add(subtype.id); + result.push(subtype); + } + } + } + } + return result; + } + + private getContainingHeritageDeclaration(node: Node): ClassDeclaration | InterfaceDeclaration | undefined { + let current: Node | undefined = node; + let inHeritageClause = false; + while (current !== undefined) { + if (isExpressionWithTypeArguments(current)) { + inHeritageClause = true; + } + if (inHeritageClause && (isClassDeclaration(current) || isInterfaceDeclaration(current))) { + return current; + } + current = current.parent; + } + return undefined; + } + + private isCurrentClass(candidate: ClassDeclaration): boolean { + const current = this.declaration.parent; + return isClassDeclaration(current) && (candidate === current || (candidate.getSourceFile().path === current.getSourceFile().path && candidate.pos === current.pos)); + } +} + +abstract class MethodBlueprintSearch extends ClassPropertyBlueprintSearch { + constructor(project: Project, symbols: Symbols, declaration: MethodDeclaration) { + super(project, symbols, declaration); + } + + public static async create(project: Project, symbols: Symbols, declaration: MethodDeclaration): Promise | undefined> { + const classDeclaration = declaration.parent; + if (!isClassDeclaration(classDeclaration)) { + return undefined; + } + const classSymbol = await symbols.getLeafSymbolAtLocation(classDeclaration.name ?? classDeclaration); + if (!Symbols.isClass(classSymbol)) { + return undefined; + } + const direct = await symbols.getDirectSuperSymbols(classSymbol); + const isPrivate = 'modifierFlags' in declaration && typeof declaration.modifierFlags === 'number' && (declaration.modifierFlags & ModifierFlags.Private) !== 0; + if (isPrivate && direct?.extends !== undefined) { + return new PrivateMethodBlueprintSearch(project, symbols, classDeclaration, direct.extends.symbol, declaration); + } + + const memberName = escapeLeadingUnderscores(declaration.name.getText()); + for (const superClass of await symbols.getAllSuperClasses(classSymbol)) { + if ((await superClass.getMembers()).has(memberName)) { + return new FindMethodInSubclassSearch(project, symbols, classDeclaration, declaration, superClass); + } + } + const typesToCheck: NativeSymbol[] = []; + for (const superType of await symbols.getAllSuperTypes(classSymbol)) { + if ((Symbols.isInterface(superType) || Symbols.isTypeLiteral(superType)) && (await superType.getMembers()).has(memberName)) { + typesToCheck.push(superType); + } + } + return typesToCheck.length === 0 ? undefined : new FindMethodInHierarchySearch(project, symbols, classDeclaration, declaration, typesToCheck); + } +} + +abstract class FindInSiblingClassSearch extends ClassPropertyBlueprintSearch { + private readonly classDeclaration: ClassDeclaration; + protected readonly extendsSymbol: NativeSymbol; + + constructor(project: Project, symbols: Symbols, classDeclaration: ClassDeclaration, extendsSymbol: NativeSymbol, declaration: T) { + super(project, symbols, declaration); + this.classDeclaration = classDeclaration; + this.extendsSymbol = extendsSymbol; + } + + public override async run(_context: RequestContext, token: CancellationTokenWithTimer): Promise { + return this.findClassWithMember([this.extendsSymbol], this.getMemberName(), token); + } + + protected abstract getMemberName(): __String; + + protected getClassDeclaration(): ClassDeclaration { + return this.classDeclaration; + } +} + +class PrivateMethodBlueprintSearch extends FindInSiblingClassSearch { + public override with(project: Project, symbols: Symbols): PrivateMethodBlueprintSearch { + return project === this.project ? this : new PrivateMethodBlueprintSearch(project, symbols, this.getClassDeclaration(), this.extendsSymbol, this.declaration); + } + + protected override getMemberName(): __String { + return escapeLeadingUnderscores(this.declaration.name.getText()); + } +} + +class FindMethodInSubclassSearch extends MethodBlueprintSearch { + private readonly classDeclaration: ClassDeclaration; + private readonly startClass: NativeSymbol; + + constructor(project: Project, symbols: Symbols, classDeclaration: ClassDeclaration, declaration: MethodDeclaration, startClass: NativeSymbol) { + super(project, symbols, declaration); + this.classDeclaration = classDeclaration; + this.startClass = startClass; + } + + public override with(project: Project, symbols: Symbols): FindMethodInSubclassSearch { + return project === this.project ? this : new FindMethodInSubclassSearch(project, symbols, this.classDeclaration, this.declaration, this.startClass); + } + + public override async run(_context: RequestContext, token: CancellationTokenWithTimer): Promise { + return this.findClassWithMember([this.startClass], escapeLeadingUnderscores(this.declaration.name.getText()), token); + } +} + +class FindMethodInHierarchySearch extends MethodBlueprintSearch { + private readonly classDeclaration: ClassDeclaration; + private readonly typesToCheck: readonly NativeSymbol[]; + + constructor(project: Project, symbols: Symbols, classDeclaration: ClassDeclaration, declaration: MethodDeclaration, typesToCheck: readonly NativeSymbol[]) { + super(project, symbols, declaration); + this.classDeclaration = classDeclaration; + this.typesToCheck = typesToCheck; + } + + public override with(project: Project, symbols: Symbols): FindMethodInHierarchySearch { + return project === this.project ? this : new FindMethodInHierarchySearch(project, symbols, this.classDeclaration, this.declaration, this.typesToCheck); + } + + public override async run(_context: RequestContext, token: CancellationTokenWithTimer): Promise { + return this.findClassWithMember(this.typesToCheck, escapeLeadingUnderscores(this.declaration.name.getText()), token); + } +} + +abstract class SimilarPropertyRunnable extends FunctionLikeContextRunnable { + constructor(session: ComputeContextSession, project: Project, context: RequestContext, declaration: T, priority: number = protocol.Priorities.Blueprints) { + super(session, project, context, 'SimilarPropertyRunnable', declaration, priority, ComputeCost.High); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + const scope = this.getCacheScope(); + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, scope === undefined ? undefined : { emitMode: protocol.EmitMode.ClientBased, scope }); + } + + protected override async run(result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const search = await this.createSearch(token); + if (search === undefined) { + return; + } + const [project, candidate] = await this.session.run(search, this.context, token); + if (project === undefined || candidate === undefined) { + return; + } + const builder = new CodeSnippetBuilder(this.context, this.context.getSymbols(project), this.declaration.getSourceFile()); + await builder.addDeclaration(candidate); + result.addSnippet(builder, this.location, undefined); + } + + protected abstract createSearch(token: CancellationTokenWithTimer): Promise | undefined>; +} + +class SimilarMethodRunnable extends SimilarPropertyRunnable { + protected override async createSearch(): Promise | undefined> { + return MethodBlueprintSearch.create(this.getProject(), this.symbols, this.declaration); + } +} + +abstract class ClassPropertyContextProvider extends FunctionLikeContextProvider { + protected readonly declaration: T; + + constructor(declaration: T, tokenInfo: TokenInfo, computeContext: ProviderComputeContext) { + super(declaration, tokenInfo, computeContext); + this.declaration = declaration; + } + + protected override async getTypeExcludes(project: Project, context: RequestContext): Promise> { + const result = new Set(); + const classDeclaration = this.declaration.parent; + if (!isClassDeclaration(classDeclaration)) { + return result; + } + const symbols = context.getSymbols(project); + for (const heritageClause of classDeclaration.heritageClauses ?? []) { + for (const type of heritageClause.types) { + // const symbol = isExpressionWithTypeArguments(type) ? await symbols.getLeafSymbolAtLocation(type.expression) : await symbols.getLeafSymbolAtLocation(type.typeName); + const symbol = await symbols.getLeafSymbolAtLocation(type.expression); + if (Symbols.isClass(symbol)) { + result.add(symbol); + } + } + } + return result; + } +} + +class PropertiesTypeRunnable extends AbstractContextRunnable { + private readonly declaration: MethodDeclaration | ConstructorDeclaration | GetAccessorDeclaration | SetAccessorDeclaration; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, declaration: MethodDeclaration | ConstructorDeclaration | GetAccessorDeclaration | SetAccessorDeclaration, priority: number = protocol.Priorities.Properties) { + super(session, project, context, 'PropertiesTypeRunnable', SnippetLocation.Secondary, priority, ComputeCost.Medium); + this.declaration = declaration; + } + + public override getActiveSourceFile(): SourceFile { + return this.declaration.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, { emitMode: protocol.EmitMode.ClientBased, scope: this.createCacheScope(this.declaration) }); + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + const containerDeclaration = this.declaration.parent; + if (!isClassDeclaration(containerDeclaration)) { + return; + } + const containerSymbol = await this.symbols.getLeafSymbolAtLocation(containerDeclaration.name ?? containerDeclaration); + if (!Symbols.isClass(containerSymbol)) { + return; + } + for (const member of (await containerSymbol.getMembers()).values()) { + token.throwIfCancellationRequested(); + if (!await this.handleMember(member, ModifierFlags.Private | ModifierFlags.Protected)) { + return; + } + } + for (const superClass of await this.symbols.getAllSuperClasses(containerSymbol)) { + for (const member of (await superClass.getMembers()).values()) { + token.throwIfCancellationRequested(); + if (!await this.handleMember(member, ModifierFlags.Public | ModifierFlags.Protected)) { + return; + } + } + } + } + + private async handleMember(symbol: NativeSymbol, flags: ModifierFlags): Promise { + const declarations = await this.symbols.getDeclarations(symbol); + if (!declarations.some(declaration => this.hasModifierFlags(declaration, flags))) { + return true; + } + for (const [typeSymbol, name] of await this.getEmitMemberData(symbol, declarations)) { + if (typeSymbol !== undefined && !await this.handleSymbol(typeSymbol, name, true)) { + return false; + } + } + return true; + } + + private async getEmitMemberData(symbol: NativeSymbol, declarations: readonly Node[]): Promise { + const result: (readonly [NativeSymbol | undefined, string | undefined])[] = []; + const type = await this.getProject().checker.getTypeOfSymbol(symbol); + if (type === undefined) { + return result; + } + if (Symbols.isProperty(symbol)) { + for (const typeSymbol of await this.symbols.getTypeSymbols(type)) { + result.push([typeSymbol, this.getDeclaredTypeName(declarations)]); + } + } else if (Symbols.isMethod(symbol)) { + for (const signature of await this.getProject().checker.getSignaturesOfType(type, SignatureKind.Call)) { + const returnType = await this.getProject().checker.getReturnTypeOfSignature(signature); + if (returnType !== undefined) { + for (const typeSymbol of await this.symbols.getTypeSymbols(returnType)) { + result.push([typeSymbol, this.getDeclaredTypeName(declarations)]); + } + } + } + } + return result; + } + + private getDeclaredTypeName(declarations: readonly Node[]): string | undefined { + for (const declaration of declarations) { + if ((isPropertyDeclaration(declaration) || isPropertySignatureDeclaration(declaration) || isMethodDeclaration(declaration) || isMethodSignatureDeclaration(declaration) || isGetAccessorDeclaration(declaration) || isSetAccessorDeclaration(declaration)) && declaration.type !== undefined && isTypeReferenceNode(declaration.type)) { + return declaration.type.typeName.getText(); + } + } + return undefined; + } + + private hasModifierFlags(node: Node, flags: ModifierFlags): boolean { + return 'modifierFlags' in node && typeof node.modifierFlags === 'number' && (node.modifierFlags & flags) !== 0; + } +} + +export class MethodContextProvider extends ClassPropertyContextProvider { + constructor(declaration: MethodDeclaration, tokenInfo: TokenInfo, computeContext: ProviderComputeContext) { + super(declaration, tokenInfo, computeContext); + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + if (session.enableBlueprintSearch()) { + result.addPrimary(new SimilarMethodRunnable(session, project, context, this.declaration)); + } + await super.provide(result, session, project, context, token); + result.addSecondary(new PropertiesTypeRunnable(session, project, context, this.declaration)); + } +} + +export class AccessorProvider extends ClassPropertyContextProvider { + constructor(declaration: GetAccessorDeclaration | SetAccessorDeclaration, tokenInfo: TokenInfo, computeContext: ProviderComputeContext) { + super(declaration, tokenInfo, computeContext); + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + await super.provide(result, session, project, context, token); + result.addSecondary(new PropertiesTypeRunnable(session, project, context, this.declaration)); + } +} + +class ConstructorBlueprintSearch extends FindInSiblingClassSearch { + public override with(project: Project, symbols: Symbols): ConstructorBlueprintSearch { + return project === this.project ? this : new ConstructorBlueprintSearch(project, symbols, this.getClassDeclaration(), this.extendsSymbol, this.declaration); + } + + protected override getMemberName(): __String { + return InternalSymbolName.Constructor; + } +} + +class SimilarConstructorRunnable extends SimilarPropertyRunnable { + protected override async createSearch(): Promise | undefined> { + const classDeclaration = this.declaration.parent; + if (!isClassDeclaration(classDeclaration)) { + return undefined; + } + const classSymbol = await this.symbols.getLeafSymbolAtLocation(classDeclaration.name ?? classDeclaration); + if (!Symbols.isClass(classSymbol)) { + return undefined; + } + const direct = await this.symbols.getDirectSuperSymbols(classSymbol); + return direct?.extends === undefined + ? undefined + : new ConstructorBlueprintSearch(this.getProject(), this.symbols, classDeclaration, direct.extends.symbol, this.declaration); + } +} + +export class ConstructorContextProvider extends ClassPropertyContextProvider { + constructor(declaration: ConstructorDeclaration, tokenInfo: TokenInfo, computeContext: ProviderComputeContext) { + super(declaration, tokenInfo, computeContext); + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + if (session.enableBlueprintSearch()) { + result.addPrimary(new SimilarConstructorRunnable(session, project, context, this.declaration)); + } + await super.provide(result, session, project, context, token); + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/moduleContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/moduleContextProvider.ts new file mode 100644 index 00000000000000..4534583766a5da --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/moduleContextProvider.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project, Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import type { ModuleDeclaration } from '@typescript/native/unstable/ast'; +import { ImportsRunnable, TypeOfExpressionRunnable, TypeOfLocalsRunnable, TypesOfNeighborFilesRunnable } from './baseContextProviders'; +import { ContextProvider, type ComputeContextSession, type ContextRunnableCollector, type ProviderComputeContext, type RequestContext } from './contextProvider'; +import type tss from './typescripts'; +import type { CancellationTokenWithTimer } from './typescripts'; + +export class ModuleContextProvider extends ContextProvider { + protected readonly declaration: ModuleDeclaration; + private readonly tokenInfo: tss.TokenInfo; + private readonly computeInfo: ProviderComputeContext; + + public override readonly isCallableProvider: boolean = true; + + constructor(declaration: ModuleDeclaration, tokenInfo: tss.TokenInfo, computeInfo: ProviderComputeContext) { + super(); + this.declaration = declaration; + this.tokenInfo = tokenInfo; + this.computeInfo = computeInfo; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + token.throwIfCancellationRequested(); + if (!this.computeInfo.isFirstCallableProvider(this)) { + return; + } + const excludes = new Set(); + result.addPrimary(new TypeOfLocalsRunnable(session, project, context, this.tokenInfo, excludes, undefined)); + const expression = TypeOfExpressionRunnable.create(session, project, context, this.tokenInfo, token); + if (expression !== undefined) { + result.addPrimary(expression); + } + result.addSecondary(new ImportsRunnable(session, project, context, this.tokenInfo, excludes)); + if (context.neighborFiles.length > 0) { + result.addTertiary(new TypesOfNeighborFilesRunnable(session, project, context, this.tokenInfo)); + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameService.ts new file mode 100644 index 00000000000000..78ef39434ecf3d --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameService.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as inspector from 'node:inspector'; + +import type { Project, Snapshot } from '@typescript/native/unstable/async'; +import type { SourceFile } from '@typescript/native/unstable/ast'; +import * as vscode from 'vscode'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import * as protocol from '../../common/serverProtocol'; +import { nesRename, prepareNesRename } from './api'; +import { TypeScript7Api } from './ts7Api'; +import { PrepareNesRenameResult } from './nesRenameValidator'; +import { CancellationTokenWithTimer, OperationCanceledException } from './typescripts'; + +type ProjectState = { + readonly project: Project; + readonly sourceFile: SourceFile; +}; + +export class TS7NesRenameService implements vscode.Disposable { + private readonly disposables = new DisposableStore(); + private readonly nativeApi: TypeScript7Api; + private readonly isDebugging: boolean; + + constructor(logService: ILogService) { + this.nativeApi = this.disposables.add(new TypeScript7Api(logService)); + this.isDebugging = inspector.url() !== undefined; + } + + public dispose(): void { + this.disposables.dispose(); + } + + public async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; + return (languageId === 'typescript' || languageId === 'typescriptreact') && await this.nativeApi.getApi() !== undefined; + } + + public async prepare(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, startTime: number, timeBudget: number, token: vscode.CancellationToken): Promise { + const no: protocol.PrepareNesRenameResult.No = { canRename: protocol.RenameKind.no, timedOut: false }; + const api = await this.nativeApi.getApi(); + if (api === undefined || token.isCancellationRequested) { + return no; + } + api.clearSourceFileCache(); + const snapshot = await api.updateSnapshot({ openFiles: [{ uri: document.uri.toString() }] }); + try { + const state = await this.getProjectState(snapshot, document); + if (state === undefined) { + return no; + } + const cancellationToken = new CancellationTokenWithTimer(token, startTime, timeBudget, this.isDebugging); + const result = new PrepareNesRenameResult(); + try { + const offset = state.sourceFile.getPositionOfLineAndCharacter(position.line, position.character); + await prepareNesRename(result, api, snapshot, state.project, state.sourceFile, offset, oldName, newName, toRange(lastSymbolRename), cancellationToken); + } catch (error) { + if (error instanceof OperationCanceledException) { + result.setCanRename(protocol.RenameKind.no, 'Operation canceled'); + } else { + throw error; + } + } + result.setTimedOut(cancellationToken.isTimedOut()); + return result.toJsonResponse(); + } finally { + await snapshot.dispose(); + } + } + + public async postRename(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, token: vscode.CancellationToken): Promise { + const api = await this.nativeApi.getApi(); + if (api === undefined || token.isCancellationRequested) { + return []; + } + api.clearSourceFileCache(); + const snapshot = await api.updateSnapshot({ openFiles: [{ uri: document.uri.toString() }] }); + try { + const state = await this.getProjectState(snapshot, document); + if (state === undefined) { + return []; + } + const cancellationToken = new CancellationTokenWithTimer(token, Date.now(), Number.MAX_VALUE, this.isDebugging); + try { + const offset = state.sourceFile.getPositionOfLineAndCharacter(position.line, position.character); + return await nesRename(api, snapshot, state.project, state.sourceFile, offset, oldName, newName, toRange(lastSymbolRename), cancellationToken); + } catch (error) { + if (error instanceof OperationCanceledException) { + return []; + } + throw error; + } + } finally { + await snapshot.dispose(); + } + } + + private async getProjectState(snapshot: Snapshot, document: vscode.TextDocument): Promise { + const identifier = { uri: document.uri.toString() }; + const project = await snapshot.getDefaultProjectForFile(identifier); + const sourceFile = await project?.program.getSourceFile(identifier); + if (project === undefined || sourceFile === undefined || sourceFile.text !== document.getText()) { + return undefined; + } + return { project, sourceFile }; + } +} + +function toRange(range: vscode.Range | undefined): protocol.Range | undefined { + return range === undefined ? undefined : { + start: { line: range.start.line, character: range.start.character }, + end: { line: range.end.line, character: range.end.character }, + }; +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameValidator.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameValidator.ts new file mode 100644 index 00000000000000..b4c93a48d1be51 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nesRenameValidator.ts @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SymbolFlags, type Project, type Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import { escapeLeadingUnderscores, isBlock, isFunctionDeclaration, isMethodDeclaration, isModuleBlock, isSourceFile, type Node } from '@typescript/native/unstable/ast'; +import * as protocol from '../../common/serverProtocol'; +import { CancellationTokenWithTimer, Symbols } from './typescripts'; + +const renameSymbolFlags = SymbolFlags.Value | SymbolFlags.Type | SymbolFlags.Namespace | SymbolFlags.Alias; + +export class PrepareNesRenameResult { + private canRename: protocol.RenameKind | undefined; + private oldName: string | undefined; + private reason: string | undefined; + private timedOut: boolean = false; + private onOldState: boolean = false; + + public getCanRename(): protocol.RenameKind | undefined { + return this.canRename; + } + + public setCanRename(value: protocol.RenameKind.no, reason?: string): PrepareNesRenameResult; + public setCanRename(value: protocol.RenameKind.yes | protocol.RenameKind.maybe, oldName: string, onOldState?: boolean): PrepareNesRenameResult; + public setCanRename(value: protocol.RenameKind, valueOrReason?: string, onOldState?: boolean): PrepareNesRenameResult { + this.canRename = value; + if (value === protocol.RenameKind.no) { + this.reason = valueOrReason; + } else { + this.oldName = valueOrReason; + this.onOldState = onOldState ?? this.onOldState; + } + return this; + } + + public setOnOldState(value: boolean): PrepareNesRenameResult { + if (this.canRename === protocol.RenameKind.no) { + throw new Error('Cannot set onOldState when canRename is no'); + } + this.onOldState = value; + return this; + } + + public setTimedOut(value: boolean): PrepareNesRenameResult { + this.timedOut = value; + return this; + } + + public toJsonResponse(): protocol.PrepareNesRenameResponse.OK { + if (this.timedOut) { + return { + canRename: protocol.RenameKind.no, + reason: this.reason, + timedOut: true, + }; + } + if (this.canRename === protocol.RenameKind.yes || this.canRename === protocol.RenameKind.maybe) { + return { + canRename: this.canRename, + oldName: this.oldName!, + onOldState: this.onOldState, + }; + } + return { + canRename: protocol.RenameKind.no, + timedOut: false, + reason: this.reason, + }; + } +} + +class DeclarationChecker { + constructor( + private readonly result: PrepareNesRenameResult, + private readonly symbols: Symbols, + private readonly symbol: NativeSymbol, + ) { } + + public async checkDeclarations(): Promise { + const declarations = await this.symbols.getDeclarations(this.symbol); + if (declarations.length <= 1) { + return; + } + let withBody = 0; + const signatures = new Set(); + for (const declaration of declarations) { + if ((isMethodDeclaration(declaration) || isFunctionDeclaration(declaration)) && declaration.body !== undefined) { + withBody++; + if (withBody === 2) { + this.result.setCanRename(protocol.RenameKind.no, 'The symbol has multiple declarations with body'); + return; + } + continue; + } + const text = declaration.getText(); + if (signatures.has(text)) { + this.result.setCanRename(protocol.RenameKind.no, 'The symbol has multiple identical declarations'); + return; + } + signatures.add(text); + } + } +} + +export async function validateNesRename(result: PrepareNesRenameResult, project: Project, node: Node, oldName: string, newName: string, token: CancellationTokenWithTimer): Promise { + const symbols = new Symbols(project, token); + const symbol = await symbols.getLeafSymbolAtLocation(node); + if (symbol === undefined) { + result.setCanRename(protocol.RenameKind.no, 'No symbol found at location'); + return; + } + const parent = await symbol.getParent(); + const declarations = await symbols.getDeclarations(symbol); + for (const declaration of declarations) { + if (await symbols.isSourceFileFromLibrary(declaration.getSourceFile())) { + result.setCanRename(protocol.RenameKind.no, 'The symbol is declared in a library file'); + return; + } + } + if (declarations.length === 1 && (Symbols.isBlockScopedVariable(symbol) || Symbols.isFunctionScopedVariable(symbol))) { + const inScope = await symbols.getSymbolsInScope(declarations[0], SymbolFlags.BlockScopedVariable | SymbolFlags.FunctionScopedVariable); + for (const inScopeSymbol of inScope) { + if (inScopeSymbol.id !== symbol.id && inScopeSymbol.name === symbol.name) { + result.setCanRename(protocol.RenameKind.no, `A variable with the name '${oldName}' already exists in the same scope`); + return; + } + } + } else if (declarations.length > 1) { + if (Symbols.isFunction(symbol)) { + await new DeclarationChecker(result, symbols, symbol).checkDeclarations(); + if (result.getCanRename() === protocol.RenameKind.no) { + return; + } + } else if (!Symbols.isMethod(symbol) || parent === undefined) { + result.setCanRename(protocol.RenameKind.no, 'The symbol has multiple declarations'); + return; + } else if (Symbols.isInterface(parent) || Symbols.isTypeLiteral(parent) || Symbols.isClass(parent)) { + await new DeclarationChecker(result, symbols, symbol).checkDeclarations(); + if (result.getCanRename() === protocol.RenameKind.no) { + return; + } + } + } + + const escapedNewName = escapeLeadingUnderscores(newName); + if (parent !== undefined) { + if ((await parent.getMembers()).has(escapedNewName)) { + result.setCanRename(protocol.RenameKind.no, `A member with the name '${newName}' already exists on '${parent.name}'`); + return; + } + if ((await parent.getExports()).has(escapedNewName)) { + result.setCanRename(protocol.RenameKind.no, `An export with the name '${newName}' already exists on module '${parent.name}'`); + return; + } + if (Symbols.isClass(parent) || Symbols.isInterface(parent)) { + for (const superType of await symbols.getAllSuperTypes(parent)) { + if ((await superType.getMembers()).has(escapedNewName)) { + result.setCanRename(protocol.RenameKind.no, `A member with the name '${newName}' already exists on base type '${superType.name}'`); + return; + } + token.throwIfCancellationRequested(); + } + result.setCanRename(protocol.RenameKind.yes, oldName); + return; + } else if (Symbols.isEnum(parent)) { + result.setCanRename(protocol.RenameKind.yes, oldName); + return; + } + } + token.throwIfCancellationRequested(); + if (declarations.length === 0) { + result.setCanRename(protocol.RenameKind.no, 'The symbol has no declarations'); + return; + } + if (await hasSameSymbolOnDeclarationSide(symbols, declarations, newName)) { + result.setCanRename(protocol.RenameKind.no, `A symbol with the name '${newName}' already exists in the scope`); + } else { + result.setCanRename(protocol.RenameKind.yes, oldName); + } +} + +async function hasSameSymbolOnDeclarationSide(symbols: Symbols, declarations: readonly Node[], newName: string): Promise { + let inModule: boolean | undefined; + for (const declaration of declarations) { + const inScope = await symbols.getTypeChecker().resolveName(newName, renameSymbolFlags, declaration, false); + if (inScope !== undefined) { + inModule ??= await isInModule(symbols, declarations); + if (!inModule) { + return true; + } + const block = getParentBlock(declaration); + if (block === undefined || await isInSameBlockScopeDeclared(symbols, inScope, block)) { + return true; + } + } + } + return false; +} + +async function isInModule(symbols: Symbols, declarations: readonly Node[]): Promise { + for (const declaration of declarations) { + // if (await symbols.getTypeChecker().getSymbolOfSourceFile(declaration.getSourceFile().fileName) === undefined) { + if (await symbols.getLeafSymbolAtLocation(declaration.getSourceFile()) === undefined) { + return false; + } + } + return true; +} + +async function isInSameBlockScopeDeclared(symbols: Symbols, symbol: NativeSymbol, block: Node): Promise { + for (const declaration of await symbols.getDeclarations(symbol)) { + if (getParentBlock(declaration) === block) { + return true; + } + } + return false; +} + +function getParentBlock(node: Node): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (isBlock(current) || isModuleBlock(current) || isSourceFile(current)) { + return current; + } + current = current.parent; + } + return undefined; +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nullContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nullContextProvider.ts new file mode 100644 index 00000000000000..661770a243aaa9 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/nullContextProvider.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project } from '@typescript/native/unstable/async'; +import { ContextProvider, type ComputeContextSession, type ContextRunnableCollector, type RequestContext } from './contextProvider'; +import type { CancellationTokenWithTimer } from './typescripts'; + +export class NullContextProvider extends ContextProvider { + public override async provide(_result: ContextRunnableCollector, _session: ComputeContextSession, _project: Project, _context: RequestContext, _token: CancellationTokenWithTimer): Promise { + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/sourceFileContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/sourceFileContextProvider.ts new file mode 100644 index 00000000000000..05e297833c6674 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/sourceFileContextProvider.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SymbolFlags, type Project, type Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import type { SourceFile } from '@typescript/native/unstable/ast'; +import { ImportsRunnable, TypeOfExpressionRunnable, TypeOfLocalsRunnable, TypesOfNeighborFilesRunnable } from './baseContextProviders'; +import { AbstractContextRunnable, ComputeCost, ContextProvider, SnippetLocation, type ComputeContextSession, type ContextResult, type ContextRunnableCollector, type ProviderComputeContext, type RequestContext, type RunnableResult } from './contextProvider'; +import * as protocol from '../../common/serverProtocol'; +import tss, { type CancellationTokenWithTimer } from './typescripts'; + +export class GlobalsRunnable extends AbstractContextRunnable { + private readonly tokenInfo: tss.TokenInfo; + + constructor(session: ComputeContextSession, project: Project, context: RequestContext, tokenInfo: tss.TokenInfo) { + super(session, project, context, 'GlobalsRunnable', SnippetLocation.Secondary, protocol.Priorities.Globals, ComputeCost.Medium); + this.tokenInfo = tokenInfo; + } + + public override getActiveSourceFile(): SourceFile { + return this.tokenInfo.token.getSourceFile(); + } + + protected override createRunnableResult(result: ContextResult): RunnableResult { + return result.createRunnableResult(this.id, this.priority, protocol.SpeculativeKind.emit, { emitMode: protocol.EmitMode.ClientBased, scope: { kind: protocol.CacheScopeKind.File } }); + } + + protected override async run(_result: RunnableResult, token: CancellationTokenWithTimer): Promise { + for (const symbol of await this.getSymbolsInScope()) { + token.throwIfCancellationRequested(); + if (!await this.handleSymbol(symbol, undefined, true)) { + break; + } + } + } + + protected async getSymbolsInScope(): Promise { + const result: NativeSymbol[] = []; + const symbols = await this.symbols.getSymbolsInScope(this.getActiveSourceFile(), SymbolFlags.Function | SymbolFlags.Class | SymbolFlags.Interface | SymbolFlags.TypeAlias | SymbolFlags.ValueModule); + for (const symbol of symbols) { + if (await this.skipSymbolBasedOnDeclaration(symbol)) { + continue; + } + result.push(await this.symbols.getLeafSymbol(symbol)); + } + return result; + } +} + +export class SourceFileContextProvider extends ContextProvider { + private readonly tokenInfo: tss.TokenInfo; + private readonly computeInfo: ProviderComputeContext; + + public override readonly isCallableProvider: boolean = true; + + constructor(tokenInfo: tss.TokenInfo, computeInfo: ProviderComputeContext) { + super(); + this.tokenInfo = tokenInfo; + this.computeInfo = computeInfo; + } + + public override async provide(result: ContextRunnableCollector, session: ComputeContextSession, project: Project, context: RequestContext, token: CancellationTokenWithTimer): Promise { + token.throwIfCancellationRequested(); + result.addSecondary(new GlobalsRunnable(session, project, context, this.tokenInfo)); + if (!this.computeInfo.isFirstCallableProvider(this)) { + return; + } + result.addPrimary(new TypeOfLocalsRunnable(session, project, context, this.tokenInfo, new Set(), undefined)); + const expression = TypeOfExpressionRunnable.create(session, project, context, this.tokenInfo, token); + if (expression !== undefined) { + result.addPrimary(expression); + } + result.addSecondary(new ImportsRunnable(session, project, context, this.tokenInfo, new Set())); + if (context.neighborFiles.length > 0) { + result.addTertiary(new TypesOfNeighborFilesRunnable(session, project, context, this.tokenInfo)); + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/nesRename.spec.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/nesRename.spec.ts new file mode 100644 index 00000000000000..c35bf75b91aead --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/nesRename.spec.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { API, type Project, type Snapshot } from '@typescript/native/unstable/async'; +import type { SourceFile } from '@typescript/native/unstable/ast'; +import type * as vscode from 'vscode'; +import { afterAll, beforeAll, suite, test } from 'vitest'; +import { z } from 'zod'; +import * as protocol from '../../../common/serverProtocol'; +import { nesRename, prepareNesRename } from '../api'; +import { PrepareNesRenameResult } from '../nesRenameValidator'; +import { CancellationTokenWithTimer } from '../typescripts'; + +const fixtures = path.join(__dirname, '../../../serverPlugin/fixtures/nes'); +const cancellationToken: vscode.CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose() { } }), +}; + +const TestAnnotationSchema = z.object({ + title: z.string(), + oldName: z.string(), + newName: z.string(), + expected: z.string(), + delta: z.number().optional(), +}); + +suite.skip('TypeScript 7 NES rename engine', () => { + let api: API; + + beforeAll(() => { + api = new API({ cwd: process.cwd() }); + }); + + afterAll(async () => { + await api.close(); + }); + + test('matches prepare rename fixture expectations', async () => { + const state = await openProject(api, 'p1'); + try { + const actual: { title: string; expected: protocol.RenameKind; result: protocol.PrepareNesRenameResult }[] = []; + const expression = /\/\/\/\/\s(\{.*\})/g; + let match: RegExpExecArray | null; + while ((match = expression.exec(state.sourceFile.text)) !== null) { + const parsed = TestAnnotationSchema.safeParse(JSON.parse(match[1])); + if (!parsed.success) { + continue; + } + const annotationPosition = state.sourceFile.getLineAndCharacterOfPosition(match.index); + const position = state.sourceFile.getPositionOfLineAndCharacter(annotationPosition.line + 1, annotationPosition.character + (parsed.data.delta ?? 0)); + const result = new PrepareNesRenameResult(); + await prepareNesRename(result, api, state.snapshot, state.project, state.sourceFile, position, parsed.data.oldName, parsed.data.newName, undefined, createToken()); + actual.push({ title: parsed.data.title, expected: protocol.RenameKind.fromString(parsed.data.expected), result: result.toJsonResponse() }); + } + assert.deepStrictEqual(actual.filter(item => item.result.canRename !== item.expected), []); + } finally { + await state.snapshot.dispose(); + } + }); + + test('prepares and computes edits on the old state', async () => { + const state = await openProject(api, 'p2'); + try { + const declarationStart = state.sourceFile.text.indexOf('bar2', state.sourceFile.text.indexOf('const bar2')); + const declarationEnd = declarationStart + 'bar2'.length; + const firstReference = state.sourceFile.text.indexOf('bar);'); + const secondReference = state.sourceFile.text.indexOf('bar);', firstReference + 1); + const lastSymbolRename: protocol.Range = { + start: toPosition(state.sourceFile, declarationStart), + end: toPosition(state.sourceFile, declarationEnd), + }; + const result = new PrepareNesRenameResult(); + await prepareNesRename(result, api, state.snapshot, state.project, state.sourceFile, firstReference, 'bar', 'bar2', lastSymbolRename, createToken()); + const groups = await nesRename(api, state.snapshot, state.project, state.sourceFile, firstReference, 'bar', 'bar2', lastSymbolRename, createToken()); + + assert.deepStrictEqual({ prepare: result.toJsonResponse(), groups }, { + prepare: { canRename: protocol.RenameKind.yes, oldName: 'bar', onOldState: true }, + groups: [{ + file: state.sourceFile.fileName, + changes: [firstReference, secondReference].map(start => ({ + range: { + start: toPosition(state.sourceFile, start), + end: toPosition(state.sourceFile, start + 'bar'.length), + }, + })), + }], + }); + } finally { + await state.snapshot.dispose(); + } + }); + + test('rejects renames of default library symbols', async () => { + // const state = await openProject(api, 'p2'); + // try { + // const oldName = 'log'; + // const newName = 'collect'; + // const firstReference = state.sourceFile.text.indexOf(`.${oldName}`) + 1; + // const secondReference = state.sourceFile.text.indexOf(`.${oldName}`, firstReference + oldName.length) + 1; + // const delta = newName.length - oldName.length; + // const lastSymbolRename: protocol.Range = { + // start: toPosition(state.sourceFile, firstReference), + // end: toPosition(state.sourceFile, firstReference + newName.length), + // }; + // const updatedText = state.sourceFile.text.substring(0, firstReference) + newName + state.sourceFile.text.substring(firstReference + oldName.length); + // const result = new PrepareNesRenameResult(); + // await prepareNesRename(result, api, state.snapshot, state.project, state.sourceFile, secondReference, oldName, newName, undefined, createToken()); + + // let groups: protocol.RenameGroup[] = []; + // await api.runWithTemporaryFileUpdate(state.snapshot, state.sourceFile.fileName, updatedText, async updatedSnapshot => { + // const updatedProject = updatedSnapshot.getProject(state.project.configFileName) ?? await updatedSnapshot.getDefaultProjectForFile(state.sourceFile.fileName); + // const updatedSourceFile = await updatedProject?.program.getSourceFile(state.sourceFile.fileName); + // assert.ok(updatedProject !== undefined && updatedSourceFile !== undefined); + // groups = await nesRename(api, updatedSnapshot, updatedProject, updatedSourceFile, secondReference + delta, oldName, newName, lastSymbolRename, createToken()); + // }); + + // assert.deepStrictEqual({ prepare: result.toJsonResponse(), groups }, { + // prepare: { canRename: protocol.RenameKind.no, timedOut: false, reason: 'The symbol is declared in a library file' }, + // groups: [], + // }); + // } finally { + // await state.snapshot.dispose(); + // } + }); +}); + +type ProjectState = { + readonly snapshot: Snapshot; + readonly project: Project; + readonly sourceFile: SourceFile; +}; + +async function openProject(api: API, projectName: string): Promise { + const projectDirectory = path.join(fixtures, projectName); + const configFile = path.join(projectDirectory, 'tsconfig.json'); + const fileName = path.join(projectDirectory, 'source/test.ts'); + assert.ok(fs.existsSync(fileName)); + const snapshot = await api.updateSnapshot({ openProjects: [configFile] }); + const project = snapshot.getProject(configFile) ?? await snapshot.getDefaultProjectForFile(fileName); + assert.ok(project !== undefined, `No project for ${fileName}`); + const sourceFile = await project.program.getSourceFile(fileName); + assert.ok(sourceFile !== undefined, `No source file for ${fileName}`); + return { snapshot, project, sourceFile }; +} + +function createToken(): CancellationTokenWithTimer { + return new CancellationTokenWithTimer(cancellationToken, Date.now(), 30_000); +} + +function toPosition(sourceFile: SourceFile, position: number): protocol.Position { + const result = sourceFile.getLineAndCharacterOfPosition(position); + return { line: result.line, character: result.character }; +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/simple.spec.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/simple.spec.ts new file mode 100644 index 00000000000000..3baac1a5009c46 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/simple.spec.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'node:assert'; +import path from 'node:path'; + +import { API } from '@typescript/native/unstable/async'; +import { version } from '@typescript/native'; +import type * as vscode from 'vscode'; +import { afterAll, beforeAll, suite, test } from 'vitest'; +import * as protocol from '../../../common/serverProtocol'; +import { computeContext } from '../api'; +import { CharacterBudget, ComputeContextSession, ContextResult, RequestContext } from '../contextProvider'; +import { CancellationTokenWithTimer } from '../typescripts'; + +const fixtures = path.join(__dirname, '../../../serverPlugin/fixtures/context'); +const cancellationToken: vscode.CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose() { } }), +}; + +suite('TypeScript 7 context engine', () => { + let api: API; + + beforeAll(() => { + api = new API({ cwd: process.cwd() }); + }); + + afterAll(async () => { + await api.close(); + }); + + test('computes compiler option traits', async () => { + const items = await compute('p1', 'source/f1.ts', 0, 0); + const traits = items.filter(item => item.kind === protocol.ContextKind.Trait).map(item => [item.name, item.value]); + assert.deepStrictEqual(traits, [ + ['The TypeScript version used in this project is ', version], + ['The TypeScript module system used in this project is ', 'Node16'], + ['The TypeScript module resolution strategy used in this project is ', 'Node16'], + ['The target version of JavaScript for this project is ', 'ES2022'], + ['Library files that should be included in TypeScript compilation are ', 'lib.es2022.d.ts,lib.dom.d.ts'], + ]); + }); + + test.skip('computes imported and local types', async () => { + const imported = await compute('p12', 'source/f2.ts', 3, 0); + const local = await compute('p12', 'source/f3.ts', 4, 0, 'TypeOfLocalsRunnable'); + const expected = normalize('declare class Person { constructor(age: number = 10); public getAlter(): number; }'); + assert.deepStrictEqual({ + imported: snippets(imported).includes(expected), + local: snippets(local).includes(expected), + }, { imported: true, local: true }); + }); + + test('computes function signature types', async () => { + const items = await compute('p7', 'source/f2.ts', 6, 0); + const values = snippets(items); + assert.deepStrictEqual([ + 'declare class Foo { public foo(): void; }', + 'interface Bar { bar(): void; }', + 'enum Enum { a = 1, b = 2 }', + 'const enum CEnum { a = 1, b = 2 }', + 'type Baz = { baz(): void; bazz: () => number; }', + ].map(value => values.includes(normalize(value))), [true, true, true, true, true]); + }); + + test('computes inherited and property types', async () => { + const inherited = await compute('p2', 'source/f2.ts', 5, 0); + const properties = await compute('p13', 'source/f2.ts', 15, 0); + assert.deepStrictEqual({ + inherited: snippets(inherited).includes(normalize('declare class B { /** * The distance between two points. */ protected distance: number; /** * The length of the line. */ protected _length: number; /** * Returns the occurrence of \'foo\'. * * @returns the occurrence of \'foo\'. */ public foo(): number; }')), + age: snippets(properties).includes(normalize('type Age = { value: number; }')), + street: snippets(properties).includes(normalize('declare class Street { constructor(name: string); public getName(); }')), + }, { inherited: true, age: true, street: true }); + }); + + test('computes expression types', async () => { + const calculator = await compute('p14', 'source/f3.ts', 4, 22); + const result = await compute('p14', 'source/f4.ts', 4, 25); + assert.deepStrictEqual({ + calculator: snippets(calculator).includes(normalize('declare class Calculator { constructor(initial: number = 0); public add(x: number): Calculator; public getResult(): Result; }')), + result: snippets(result).includes(normalize('interface Result { value: number; message: string; }')), + }, { calculator: true, result: true }); + }); + + test('computes class, method, and constructor blueprints', async () => { + const classItems = snippets(await compute('p1', 'source/f3.ts', 3, 0)); + const methodItems = snippets(await compute('p5', 'source/f3.ts', 4, 0)); + const constructorItems = snippets(await compute('p8', 'source/f3.ts', 5, 0)); + assert.deepStrictEqual({ + class: classItems.includes(normalize('export class X implements Name, NameLength { name() { return \'x\'; } length() { return \'x\'.length; } }')), + method: methodItems.includes(normalize('/** * Javadoc */ export class Bar extends Foo { private name(): string { return \'Bar\'; } }')), + constructor: constructorItems.includes(normalize('/** * Javadoc */ export class Bar extends Foo { private name: string; constructor() { super(); this.name = \'Bar\'; } }')), + }, { class: true, method: true, constructor: true }); + }); + + async function compute(projectName: string, relativeFile: string, line: number, character: number, runnableId?: protocol.ContextRunnableResultId): Promise { + const projectDirectory = path.join(fixtures, projectName); + const configFile = path.join(projectDirectory, 'tsconfig.json'); + const fileName = path.join(projectDirectory, relativeFile); + const snapshot = await api.updateSnapshot({ openProjects: [configFile] }); + try { + const project = snapshot.getProject(configFile) ?? await snapshot.getDefaultProjectForFile(fileName); + assert.ok(project !== undefined, `No project for ${fileName}`); + const sourceFile = await project.program.getSourceFile(fileName); + assert.ok(sourceFile !== undefined, `No source file for ${fileName}`); + const startTime = Date.now(); + const token = new CancellationTokenWithTimer(cancellationToken, startTime, 30_000); + const session = new TestComputeContextSession(project, token); + const context = new RequestContext(session, [], new Map(), true); + const result = new ContextResult(new CharacterBudget(7 * 1024 * 4), new CharacterBudget(8 * 1024 * 4), context); + const position = sourceFile.getPositionOfLineAndCharacter(line, character); + await computeContext(result, session, project, sourceFile, position, token); + return resolveItems(result.toJson(), runnableId); + } finally { + await snapshot.dispose(); + } + } +}); + +class TestComputeContextSession extends ComputeContextSession { + public override enableBlueprintSearch(): boolean { + return true; + } +} + +function resolveItems(response: protocol.ComputeContextResponse.OK, runnableId?: protocol.ContextRunnableResultId): protocol.FullContextItem[] { + const itemMap = new Map(); + for (const item of response.contextItems ?? []) { + if (item.kind !== protocol.ContextKind.Reference && protocol.ContextItem.hasKey(item)) { + itemMap.set(item.key, item); + } + } + const result: protocol.FullContextItem[] = []; + const seen = new Set(); + for (const runnable of response.runnableResults ?? []) { + if (runnable.kind !== protocol.ContextRunnableResultKind.ComputedResult || (runnableId !== undefined && runnable.id !== runnableId)) { + continue; + } + for (const item of runnable.items) { + if (item.kind === protocol.ContextKind.Reference) { + if (seen.has(item.key)) { + continue; + } + const referenced = itemMap.get(item.key); + if (referenced !== undefined) { + seen.add(item.key); + result.push(referenced); + } + } else { + result.push(item); + } + } + } + return result; +} + +function snippets(items: readonly protocol.FullContextItem[]): string[] { + return items.filter(item => item.kind === protocol.ContextKind.Snippet).map(item => normalize(item.value)); +} + +function normalize(value: string): string { + return value.trim().replace(/\r?\n/g, ' ').replace(/\t+/g, ' ').replace(/\s+/g, ' '); +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/ts7Api.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/ts7Api.ts new file mode 100644 index 00000000000000..6e609c6d3bcc71 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/ts7Api.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { API } from '@typescript/native/unstable/async'; +import * as vscode from 'vscode'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { TypeScript } from '../tsService'; + +interface TypeScript7ExtensionApi { + onLanguageServerInitialized: vscode.Event; + initializeAPIConnection(pipePath?: string): Promise; +} + +export class TypeScript7Api implements vscode.Disposable { + private static connection: TypeScript7Connection | undefined; + private static refCount: number = 0; + + private readonly connection: TypeScript7Connection; + private disposed: boolean = false; + + public readonly onDidReconnect: vscode.Event; + + constructor(logService: ILogService) { + TypeScript7Api.connection ??= new TypeScript7Connection(logService); + TypeScript7Api.refCount++; + this.connection = TypeScript7Api.connection; + this.onDidReconnect = this.connection.onDidReconnect; + } + + public getApi(): Promise | undefined> { + return this.connection.getApi(); + } + + public dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + if (--TypeScript7Api.refCount === 0) { + TypeScript7Api.connection = undefined; + this.connection.dispose(); + } + } +} + +/** + * The actual connection to the TypeScript 7 language server. Shared by all {@link TypeScript7Api} handles + * so that we only ever open one pipe to the server. + */ +class TypeScript7Connection implements vscode.Disposable { + + private static readonly maxConnectAttempts: number = 3; + + private readonly disposables = new DisposableStore(); + private readonly onDidReconnectEmitter = this.disposables.add(new vscode.EventEmitter()); + + public readonly onDidReconnect = this.onDidReconnectEmitter.event; + + private api: API | undefined; + private apiPromise: Promise | undefined> | undefined; + private extensionApi: TypeScript7ExtensionApi | undefined; + private generation: number = 0; + private disposed: boolean = false; + + constructor(private readonly logService: ILogService) { } + + public getApi(): Promise | undefined> { + if (this.api !== undefined) { + return Promise.resolve(this.api); + } + if (this.disposed) { + return Promise.resolve(undefined); + } + if (this.apiPromise === undefined) { + const promise = this.createApi(); + this.apiPromise = promise; + void promise.then(() => { + if (this.apiPromise === promise) { + this.apiPromise = undefined; + } + }); + } + return this.apiPromise; + } + + public dispose(): void { + this.disposed = true; + this.resetApi(); + this.disposables.dispose(); + } + + private async createApi(): Promise | undefined> { + try { + const extensionApi = await this.getExtensionApi(); + if (extensionApi === undefined) { + return undefined; + } + for (let attempt = 0; attempt < TypeScript7Connection.maxConnectAttempts; attempt++) { + const generation = this.generation; + const pipe = await extensionApi.initializeAPIConnection(); + const api = await API.fromLSPConnection({ pipe }); + if (this.disposed) { + this.close(api); + return undefined; + } + if (this.generation === generation) { + this.api = api; + return api; + } + // The language server (re)initialized while we were connecting, so this pipe is already stale. + this.close(api); + } + return undefined; + } catch (error) { + this.logService.error(error, 'Error connecting to the TypeScript 7 API'); + return undefined; + } + } + + private async getExtensionApi(): Promise { + if (this.extensionApi !== undefined) { + return this.extensionApi; + } + const extension = TypeScript.getVersion7Extension(); + if (extension === undefined) { + return undefined; + } + const extensionApi = await extension.activate(); + if (this.disposed) { + return undefined; + } + if (this.extensionApi === undefined) { + this.extensionApi = extensionApi; + this.disposables.add(extensionApi.onLanguageServerInitialized(() => this.reconnect())); + } + return this.extensionApi; + } + + private reconnect(): void { + // The initial initialization arrives while we are still connecting. Bump the generation so that + // connect picks up the new pipe, but only tell consumers when an established connection went away. + const hadApi = this.api !== undefined; + this.resetApi(); + if (hadApi) { + this.onDidReconnectEmitter.fire(); + } + } + + private resetApi(): void { + this.generation++; + const api = this.api; + this.api = undefined; + if (api !== undefined) { + this.close(api); + } + } + + private close(api: API): void { + api.close().catch(error => this.logService.error(error, 'Error closing stale TypeScript 7 API connection')); + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/tsContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/tsContextService.ts new file mode 100644 index 00000000000000..724d5524dbcf7a --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/tsContextService.ts @@ -0,0 +1,436 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as inspector from 'inspector'; + +import type { API } from '@typescript/native/unstable/async'; + +import { IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import { type ContextItem, type RequestContext, KnownSources } from '../../../../platform/languageServer/common/languageContextService'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; +import * as protocol from '../../common/serverProtocol'; +import { ContextItemResultBuilder, ResolvedRunnableResult } from '../types'; +import { AbstractTSLanguageContextService, currentTokenBudget } from '../tsContextService'; +import { computeContext as computeServerContext } from './api'; +import { CharacterBudget, ComputeContextSession, ContextResult, RequestContext as ServerRequestContext, TokenBudgetExhaustedError } from './contextProvider'; +import { CancellationTokenWithTimer, OperationCanceledException } from './typescripts'; +import { TypeScript7Api } from './ts7Api'; + +class PendingRequestInfo { + public readonly document: string; + public readonly version: number; + public readonly position: vscode.Position; + public readonly context: RequestContext; + + constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext) { + this.document = document.uri.toString(); + this.version = document.version; + this.position = position; + this.context = context; + } +} + +type ComputeContextResult = + | { readonly kind: 'ok'; readonly body: protocol.ComputeContextResponse.OK } + | { readonly kind: 'cancelled' } + | { readonly kind: 'unavailable' } + | { readonly kind: 'failed'; readonly error: protocol.CustomResponse.Failed }; + +namespace ComputeContextResult { + export const cancelled: ComputeContextResult = { kind: 'cancelled' }; + export const unavailable: ComputeContextResult = { kind: 'unavailable' }; + + export function ok(body: protocol.ComputeContextResponse.OK): ComputeContextResult { + return { kind: 'ok', body }; + } + + export function toErrorData(error: unknown): protocol.CustomResponse.Failed { + return error instanceof Error + ? { error: protocol.ErrorCode.exception, message: error.message, stack: error.stack } + : { error: protocol.ErrorCode.exception, message: 'Unknown error' }; + } + + export function failed(error: unknown): ComputeContextResult { + return { kind: 'failed', error: toErrorData(error) }; + } +} + +class InflightRequestInfo { + public readonly document: string; + public readonly position: vscode.Position; + public readonly requestId: string; + public readonly source: KnownSources | string; + public readonly serverPromise: Promise; + + private readonly tokenSource: vscode.CancellationTokenSource; + + constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, tokenSource: vscode.CancellationTokenSource, serverPromise: Promise) { + this.document = document.uri.toString(); + this.position = position; + this.requestId = context.requestId; + this.source = context.source ?? KnownSources.unknown; + this.tokenSource = tokenSource; + this.serverPromise = serverPromise; + } + + public matches(document: vscode.TextDocument, position: vscode.Position): boolean { + return this.document === document.uri.toString() && this.position.isEqual(position); + } + + public matchesDocument(document: vscode.TextDocument): boolean { + return this.document === document.uri.toString(); + } + + public cancel(): void { + this.tokenSource.cancel(); + } +} + +class OnTimeoutData { + private readonly document: string; + private readonly version: number; + private readonly position: vscode.Position; + + public readonly runnableResults: ResolvedRunnableResult[] = []; + public resultBuilder: ContextItemResultBuilder | undefined; + + constructor(document: vscode.TextDocument, position: vscode.Position) { + this.document = document.uri.toString(); + this.version = document.version; + this.position = position; + } + + public addRunnableResults(results: readonly ResolvedRunnableResult[]): void { + this.runnableResults.push(...results); + } + + public matches(document: vscode.TextDocument, position: vscode.Position): boolean { + return this.document === document.uri.toString() && this.version === document.version && this.position.isEqual(position); + } +} + +export class TS7LanguageContextService extends AbstractTSLanguageContextService { + private static readonly defaultCachePopulationRaceTimeout: number = 20; + + private readonly nativeApi: TypeScript7Api; + private readonly isDebugging: boolean; + private pendingRequest: PendingRequestInfo | undefined; + private inflightCachePopulationRequest: InflightRequestInfo | undefined; + private onTimeoutData: OnTimeoutData | undefined; + + constructor( + telemetryService: ITelemetryService, + configurationService: IConfigurationService, + experimentationService: IExperimentationService, + logService: ILogService + ) { + super(telemetryService, logService, configurationService, experimentationService); + this.isDebugging = inspector?.url() !== undefined; + this.nativeApi = this.disposables.add(new TypeScript7Api(logService)); + this.disposables.add(this.nativeApi.onDidReconnect(() => this.reconnect())); + } + + public override dispose(): void { + this.inflightCachePopulationRequest?.cancel(); + this.inflightCachePopulationRequest = undefined; + super.dispose(); + } + + async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; + if (languageId !== 'typescript' && languageId !== 'typescriptreact') { + return false; + } + return await this.getApi() !== undefined; + } + + async populateCache(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): Promise { + if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { + return; + } + if (this.inflightCachePopulationRequest !== undefined) { + if (!this.inflightCachePopulationRequest.matches(document, position)) { + this.pendingRequest = new PendingRequestInfo(document, position, context); + } + return; + } + const startTime = Date.now(); + const contextRequestState = this.runnableResultManager.getContextRequestState(document, position); + if (contextRequestState !== undefined && contextRequestState.server.length === 0) { + return; + } + const neighborFiles = this.neighborFileModel.getNeighborFiles(document); + const timeBudget = this.cachePopulationTimeout; + try { + const isDebugging = this.isDebugging; + const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; + const tokenSource = new vscode.CancellationTokenSource(); + const token = tokenSource.token; + const documentVersion = document.version; + const cacheState = this.runnableResultManager.getCacheState(); + let result: ComputeContextResult; + const promise = this.computeContext(document, position, context, startTime, timeBudget, neighborFiles, contextRequestState?.server, token); + const inflightRequest = new InflightRequestInfo(document, position, context, tokenSource, promise); + this.inflightCachePopulationRequest = inflightRequest; + try { + result = await promise; + } finally { + if (this.inflightCachePopulationRequest === inflightRequest) { + this.inflightCachePopulationRequest = undefined; + } + tokenSource.dispose(); + } + if (result.kind === 'unavailable') { + return; + } + const timeTaken = Date.now() - startTime; + if (result.kind === 'cancelled') { + this.telemetrySender.sendRequestCancelledTelemetry(context, timeTaken); + } else if (result.kind === 'failed') { + this.telemetrySender.sendRequestFailureTelemetry(context, result.error); + this.logService.error(`Error computing TypeScript 7 context for document: ${document.uri.toString()} at position: ${position.line + 1}:${position.character + 1}`, result.error.stack ?? result.error.message); + } else { + const body = result.body; + const contextItemResult = new ContextItemResultBuilder(timeTaken); + const { resolved, cached, referenced, serverComputed } = this.runnableResultManager.update(document, documentVersion, position, context, body, contextRequestState); + contextItemResult.cachedItems += cached; + contextItemResult.referencedItems += referenced; + contextItemResult.serverComputed = serverComputed; + for (const runnableResult of resolved) { + for (const converted of contextItemResult.update(runnableResult)) { + forDebugging?.push(converted.item); + } + } + contextItemResult.updateResponse(body, token); + this.telemetrySender.sendRequestTelemetry(document, position, context, contextItemResult, timeTaken, { before: cacheState, after: this.runnableResultManager.getCacheState() }, undefined); + // eslint-disable-next-line local/code-no-unused-expressions + isDebugging && forDebugging?.length; + this._onCachePopulated.fire({ document, position, source: context.source, items: resolved, summary: contextItemResult }); + } + } catch (error) { + this.telemetrySender.sendRequestFailureTelemetry(context, ComputeContextResult.toErrorData(error)); + this.logService.error(error, `Error populating cache for document: ${document.uri.toString()} at position: ${position.line + 1}:${position.character + 1}`); + } finally { + this.runPendingRequest(); + } + } + + private async computeContext(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, startTime: number, timeBudget: number, neighborFiles: readonly string[], clientSideRunnableResults: readonly protocol.CachedContextRunnableResult[] | undefined, token: vscode.CancellationToken): Promise { + try { + const api = await this.getApi(); + if (api === undefined) { + return ComputeContextResult.unavailable; + } + // Workaround for https://github.com/microsoft/typescript-go/issues/4916 + api.clearSourceFileCache(); + const snapshot = await api.updateSnapshot({ openFiles: [ { uri: document.uri.toString() } ] }); + try { + if (token.isCancellationRequested) { + return ComputeContextResult.cancelled; + } + const project = await snapshot.getDefaultProjectForFile({ uri: document.uri.toString() }); + if (project === undefined) { + return ComputeContextResult.cancelled; + } + const sourceFile = await project.program.getSourceFile({ uri: document.uri.toString() }); + if (sourceFile === undefined || sourceFile.text !== document.getText()) { + return ComputeContextResult.cancelled; + } + const cancellationToken = new CancellationTokenWithTimer(token, startTime, timeBudget, this.isDebugging); + const session = new ComputeContextSession(project, cancellationToken); + const cachedResults = clientSideRunnableResults ?? []; + const requestContext = new ServerRequestContext(session, neighborFiles, new Map(cachedResults.map(result => [result.id, result])), this.includeDocumentation); + const result = new ContextResult( + new CharacterBudget((context.tokenBudget ?? 7 * 1024) * 4), + new CharacterBudget(currentTokenBudget * 4), + requestContext, + ); + const computeStart = Date.now(); + try { + const offset = sourceFile.getPositionOfLineAndCharacter(position.line, position.character); + await computeServerContext(result, session, project, sourceFile, offset, cancellationToken); + } catch (error) { + if (error instanceof OperationCanceledException) { + if (token.isCancellationRequested) { + throw error; + } + } else if (!(error instanceof TokenBudgetExhaustedError)) { + throw error; + } + } + const endTime = Date.now(); + result.addTimings(endTime - startTime, endTime - computeStart); + result.setTimedOut(cancellationToken.isTimedOut()); + return ComputeContextResult.ok(result.toJson()); + } finally { + await snapshot.dispose(); + } + } catch (error) { + // Never reject: the same promise is raced by `getContext`. + return error instanceof OperationCanceledException ? ComputeContextResult.cancelled : ComputeContextResult.failed(error); + } + } + + private runPendingRequest(): void { + if (this.pendingRequest === undefined) { + return; + } + const pendingRequest = this.pendingRequest; + this.pendingRequest = undefined; + const document = vscode.window.activeTextEditor?.document; + if (document !== undefined && document.uri.toString() === pendingRequest.document && document.version === pendingRequest.version && document.validatePosition(pendingRequest.position).isEqual(pendingRequest.position)) { + this.populateCache(document, pendingRequest.position, pendingRequest.context).catch(() => { /* handled in populateCache */ }); + } + } + + public async *getContext(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, token: vscode.CancellationToken): AsyncIterable { + this.onTimeoutData = undefined; + if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { + return; + } + + const startTime = Date.now(); + let cacheRequest = 'none'; + const cachePopulationRequestInflight = this.inflightCachePopulationRequest !== undefined && this.inflightCachePopulationRequest.matchesDocument(document); + if (cachePopulationRequestInflight) { + this.onTimeoutData = new OnTimeoutData(document, position); + } + if (token.isCancellationRequested) { + this.telemetrySender.sendRequestCancelledTelemetry(context, Date.now() - startTime); + return; + } + + const isDebugging = this.isDebugging; + const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; + const contextItemResult = new ContextItemResultBuilder(Date.now() - startTime); + if (this.onTimeoutData !== undefined) { + this.onTimeoutData.resultBuilder = contextItemResult; + } + const characterBudget = this.getCharacterBudget(context, document); + const itemsToYield: ContextItem[] = []; + const { mandatory, optional, onTimeout } = this.getRunnables(document, position, cachePopulationRequestInflight); + this.onTimeoutData?.addRunnableResults(onTimeout); + + outer: for (const runnableResult of mandatory) { + for (const { item, size } of contextItemResult.update(runnableResult, true)) { + forDebugging?.push(item); + characterBudget.spend(size); + if (characterBudget.isExhausted()) { + break outer; + } + itemsToYield.push(item); + } + } + if (!characterBudget.isOptionalExhausted()) { + outer: for (const runnableResult of optional) { + for (const { item, size } of contextItemResult.update(runnableResult, true)) { + forDebugging?.push(item); + characterBudget.spend(size); + if (characterBudget.isOptionalExhausted()) { + break outer; + } + itemsToYield.push(item); + } + } + } + + if (!token.isCancellationRequested) { + for (const item of itemsToYield) { + if (token.isCancellationRequested) { + this.onTimeoutData = undefined; + return; + } + yield item; + } + + const inflightRequest = this.inflightCachePopulationRequest; + if (inflightRequest !== undefined && inflightRequest.matchesDocument(document)) { + cacheRequest = 'inflight'; + const timeout = Math.max(0, Math.min(context.timeBudget ?? TS7LanguageContextService.defaultCachePopulationRaceTimeout, TS7LanguageContextService.defaultCachePopulationRaceTimeout)); + const response = await Promise.race([ + inflightRequest.serverPromise, + new Promise<'timedOut'>(resolve => setTimeout(() => resolve('timedOut'), timeout)), + ]); + if (response !== 'timedOut') { + if (this.onTimeoutData !== undefined) { + this.onTimeoutData = undefined; + for (const runnableResult of this.runnableResultManager.getCachedRunnableResults(document, position, protocol.EmitMode.ClientBasedOnTimeout)) { + for (const { item } of contextItemResult.update(runnableResult)) { + forDebugging?.push(item); + yield item; + } + } + cacheRequest = 'awaited'; + } + } + } + } else { + this.onTimeoutData = undefined; + } + + if (context.proposedEdits !== undefined) { + this.telemetrySender.sendSpeculativeRequestTelemetry(context, this.runnableResultManager.getRequestId() ?? 'unknown', contextItemResult.stats.yielded); + } else { + const cacheState = this.runnableResultManager.getCacheState(); + contextItemResult.path = this.runnableResultManager.getNodePath(); + contextItemResult.cancelled = token.isCancellationRequested; + contextItemResult.serverTime = 0; + contextItemResult.contextComputeTime = 0; + contextItemResult.fromCache = true; + this.telemetrySender.sendRequestTelemetry(document, position, context, contextItemResult, Date.now() - startTime, { before: cacheState, after: cacheState }, cacheRequest); + // eslint-disable-next-line local/code-no-unused-expressions + isDebugging && forDebugging?.length; + this._onContextComputed.fire({ document, position, source: context.source, items: itemsToYield, summary: contextItemResult }); + } + } + + private getRunnables(document: vscode.TextDocument, position: vscode.Position, cachePopulationInflight: boolean): { mandatory: readonly ResolvedRunnableResult[]; optional: readonly ResolvedRunnableResult[]; onTimeout: readonly ResolvedRunnableResult[] } { + const mandatory: ResolvedRunnableResult[] = []; + const optional: ResolvedRunnableResult[] = []; + const onTimeout: ResolvedRunnableResult[] = []; + for (const runnable of this.runnableResultManager.getCachedRunnableResults(document, position)) { + if (cachePopulationInflight && runnable.cache?.emitMode === protocol.EmitMode.ClientBasedOnTimeout) { + onTimeout.push(runnable); + } else if (runnable.priority === protocol.Priorities.Expression || runnable.priority === protocol.Priorities.Locals || runnable.priority === protocol.Priorities.Inherited || runnable.priority === protocol.Priorities.Traits) { + mandatory.push(runnable); + } else { + optional.push(runnable); + } + } + return { mandatory, optional, onTimeout }; + } + + public getContextOnTimeout(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): readonly ContextItem[] | undefined { + try { + if (this.onTimeoutData === undefined || !this.onTimeoutData.matches(document, position) || this.onTimeoutData.resultBuilder === undefined) { + return []; + } + const result: ContextItem[] = []; + for (const runnableResult of this.onTimeoutData.runnableResults) { + for (const { item } of this.onTimeoutData.resultBuilder.update(runnableResult, true)) { + result.push(item); + } + } + return result; + } finally { + this.onTimeoutData = undefined; + } + } + + private async getApi(): Promise | undefined> { + return this.nativeApi.getApi(); + } + + private reconnect(): void { + this.inflightCachePopulationRequest?.cancel(); + this.inflightCachePopulationRequest = undefined; + this.pendingRequest = undefined; + this.onTimeoutData = undefined; + this.runnableResultManager.clear(); + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/types.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/types.ts new file mode 100644 index 00000000000000..6a89d9e55df58c --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/types.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Project, Symbol as NativeSymbol } from '@typescript/native/unstable/async'; +import type { Node, SourceFile } from '@typescript/native/unstable/ast'; +import type * as protocol from '../../common/serverProtocol'; +import type { Symbols } from './typescripts'; + +export interface SnippetProvider { + isEmpty(): boolean; + snippet(key: string | undefined): protocol.CodeSnippet; +} + +export type CodeCacheItem = { + value: string[]; + uri: string; + additionalUris?: Set; +}; + +export interface EmitterContext { + getCachedCode(key: string): CodeCacheItem | undefined; + cacheCode(key: string, code: CodeCacheItem): void; +} + +export abstract class ProgramContext { + protected async getSymbolInfo(symbol: NativeSymbol): Promise<{ skip: true } | { skip: false; primary: SourceFile; declarations: readonly Node[] }> { + const declarations = await this.getSymbols().getDeclarations(symbol); + if (declarations.length === 0) { + return { skip: true }; + } + let primary: SourceFile | undefined; + for (const declaration of declarations) { + const sourceFile = declaration.getSourceFile(); + primary ??= sourceFile; + if (await this.skipDeclaration(declaration, sourceFile)) { + return { skip: true }; + } + } + return primary === undefined ? { skip: true } : { skip: false, primary, declarations }; + } + + protected async skipDeclaration(_declaration: Node, sourceFile: SourceFile): Promise { + const metadata = await this.getProject().program.getSourceFileMetadataByPath(sourceFile.path); + return metadata?.isDefaultLibrary === true || metadata?.isFromExternalLibrary === true; + } + + protected abstract getProject(): Project; + protected abstract getSymbols(): Symbols; +} + +export class RecoverableError extends Error { + public static readonly SourceFileNotFound: number = 1; + public static readonly NodeNotFound: number = 2; + public static readonly NodeKindMismatch: number = 3; + public static readonly SymbolNotFound: number = 4; + public static readonly NoDeclaration: number = 5; + public static readonly NoProgram: number = 6; + public static readonly NoSourceFile: number = 7; + + public readonly code: number; + + constructor(message: string, code: number) { + super(message); + this.code = code; + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts new file mode 100644 index 00000000000000..b5b2908c5ebbee --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts @@ -0,0 +1,488 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createHash } from 'node:crypto'; + +import { Symbol as NativeSymbol, SymbolFlags, type NodeHandle, type Program, type Project, type Type, type DocumentPosition } from '@typescript/native/unstable/async'; +import { + findPrecedingToken, + getTokenAtPosition, + isBlock, + isClassDeclaration, + isInterfaceDeclaration, + isModuleBlock, + isSourceFile, + isTypeAliasDeclaration, + isTypeReferenceNode, + SyntaxKind, + type Node, + type SourceFile, + type TypeNode, + type DeclarationBase +} from '@typescript/native/unstable/ast'; +import type * as vscode from 'vscode'; + +export class OperationCanceledException extends Error { + constructor() { + super('TypeScript 7 context request cancelled'); + } +} + +export class CancellationTokenWithTimer { + private readonly cancellationToken: vscode.CancellationToken; + private readonly end: number; + + constructor(cancellationToken: vscode.CancellationToken, startTime: number, budget: number, isDebugging: boolean = false) { + this.cancellationToken = cancellationToken; + this.end = isDebugging ? Number.MAX_VALUE : startTime + budget; + } + + public isCancellationRequested(): boolean { + return this.cancellationToken.isCancellationRequested || this.isTimedOut(); + } + + public isTimedOut(): boolean { + return Date.now() > this.end; + } + + public throwIfCancellationRequested(): void { + if (this.isCancellationRequested()) { + throw new OperationCanceledException(); + } + } +} + +namespace tss { + export type TokenInfo = { + token: Node; + touching?: Node; + previous?: Node; + }; + + export function getRelevantTokens(sourceFile: SourceFile, position: number): TokenInfo { + const token = getTokenAtPosition(sourceFile, position); + const result: TokenInfo = { token }; + if (token.kind === SyntaxKind.EndOfFile) { + result.previous = findPrecedingToken(sourceFile, position); + return result; + } + + const start = token.getStart(sourceFile); + if (position > start) { + result.touching = token; + } else if (position < start) { + let candidate: Node | undefined = token.parent; + while (candidate !== undefined) { + if (position >= candidate.getStart(sourceFile)) { + result.touching = candidate; + break; + } + candidate = candidate.parent; + } + } + result.previous = findPrecedingToken(sourceFile, position); + return result; + } + + export namespace Nodes { + export function getChildren(node: Node): readonly Node[] { + if (isSourceFile(node)) { + return node.statements; + } + const result: Node[] = []; + node.forEachChild(child => { + result.push(child); + return undefined; + }); + return result; + } + + export function getTypeName(node: TypeNode): string | undefined { + return isTypeReferenceNode(node) ? node.typeName.getText() : undefined; + } + + export function getParentOfKind(node: Node, kind: SyntaxKind): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (current.kind === kind) { + return current; + } + current = current.parent; + } + return undefined; + } + + export function getParentBlock(node: Node): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (isBlock(current) || isModuleBlock(current) || isSourceFile(current)) { + return current; + } + current = current.parent; + } + return undefined; + } + } + + export namespace StableSyntaxKinds { + export function getPath(node: Node): number[] { + const result: number[] = []; + let current: Node | undefined = node; + while (current !== undefined) { + result.push(current.kind); + if (isSourceFile(current)) { + break; + } + current = current.parent; + } + return result; + } + } +} + +export type TokenInfo = tss.TokenInfo; + +export type DirectSuperSymbolInfo = { + extends?: { symbol: NativeSymbol; name: string }; + implements?: { symbol: NativeSymbol; name: string }[]; +}; + +export type SymbolInfo = { + symbol: NativeSymbol; + primary: SourceFile; + declarations: readonly Node[]; +}; + +export class Symbols { + private readonly project: Project; + private readonly token: CancellationTokenWithTimer; + private readonly declarationCache: Map> = new Map(); + + constructor(project: Project, token: CancellationTokenWithTimer) { + this.project = project; + this.token = token; + } + + public getProject(): Project { + return this.project; + } + + public getProgram(): Program { + return this.project.program; + } + + public getTypeChecker(): Project['checker'] { + return this.project.checker; + } + + public async isSourceFileFromLibrary(sourceFile: SourceFile): Promise { + this.token.throwIfCancellationRequested(); + const isDefaultLibrary = await this.project.program.isSourceFileDefaultLibrary(sourceFile); + this.token.throwIfCancellationRequested(); + if (isDefaultLibrary) { + return true; + } + const isExternalLibrary = await this.project.program.isSourceFileFromExternalLibrary(sourceFile); + this.token.throwIfCancellationRequested(); + return isExternalLibrary; + } + + public async getSymbolAtLocation(node: Node): Promise { + this.token.throwIfCancellationRequested(); + const result = await this.project.checker.getSymbolAtLocation(node); + this.token.throwIfCancellationRequested(); + return result; + } + + public async getSymbolsInScope(location: Node | DocumentPosition, meaning: SymbolFlags): Promise { + interface CheckerWithSymbolsInScope { + getSymbolsInScope(location: Node | DocumentPosition, meaning: SymbolFlags): readonly NativeSymbol[]; + } + const checker = this.project.checker; + if (typeof (checker as unknown as CheckerWithSymbolsInScope).getSymbolsInScope === 'function') { + return (checker as unknown as CheckerWithSymbolsInScope).getSymbolsInScope(location, meaning); + } + return []; + } + + public async getAliasedSymbol(symbol: NativeSymbol): Promise { + return Symbols.isAlias(symbol) ? this.getLeafSymbol(symbol) : symbol; + } + + public async getAliasedSymbolAtLocation(node: Node): Promise { + const symbol = await this.getSymbolAtLocation(node); + return symbol === undefined ? undefined : this.getAliasedSymbol(symbol); + } + + public async getLeafSymbolAtLocation(node: Node): Promise { + const symbol = await this.getSymbolAtLocation(node); + return symbol === undefined ? undefined : this.getLeafSymbol(symbol); + } + + public async getLeafSymbol(initialSymbol: NativeSymbol): Promise { + let symbol = initialSymbol; + let count = 0; + while (Symbols.isAlias(symbol) && count++ < 10) { + this.token.throwIfCancellationRequested(); + const candidate = await this.project.checker.getAliasedSymbol(symbol); + this.token.throwIfCancellationRequested(); + if (candidate.id === symbol.id || await this.project.checker.isUnknownSymbol(candidate)) { + break; + } + symbol = candidate; + } + while (Symbols.isTypeAlias(symbol) && count++ < 10) { + const declarations = await this.getDeclarations(symbol); + if (declarations.length !== 1 || !isTypeAliasDeclaration(declarations[0])) { + break; + } + const candidate = await this.getSymbolAtLocation(declarations[0].type); + if (candidate === undefined || candidate.id === symbol.id) { + break; + } + symbol = candidate; + } + return symbol; + } + + public getDeclarations(symbol: NativeSymbol): Promise { + let result = this.declarationCache.get(symbol.id); + if (result === undefined) { + result = this.resolveDeclarations(symbol.declarations); + this.declarationCache.set(symbol.id, result); + } + return result; + } + + public async getSymbolInfo(symbol: NativeSymbol, activeSourceFile?: SourceFile): Promise { + const declarations = await this.getDeclarations(symbol); + if (declarations.length === 0) { + return undefined; + } + let primary: SourceFile | undefined; + for (const declaration of declarations) { + const sourceFile = declaration.getSourceFile(); + primary ??= sourceFile; + if (activeSourceFile !== undefined && sourceFile.path === activeSourceFile.path) { + return undefined; + } + this.token.throwIfCancellationRequested(); + const metadata = await this.project.program.getSourceFileMetadataByPath(sourceFile.path); + this.token.throwIfCancellationRequested(); + if (metadata?.isDefaultLibrary || metadata?.isFromExternalLibrary) { + return undefined; + } + } + return primary === undefined ? undefined : { symbol, primary, declarations }; + } + + public async getDirectSuperSymbols(symbol: NativeSymbol): Promise { + const result: DirectSuperSymbolInfo = {}; + for (const declaration of await this.getDeclarations(symbol)) { + if (!isClassDeclaration(declaration) && !isInterfaceDeclaration(declaration)) { + continue; + } + for (const heritageClause of declaration.heritageClauses ?? []) { + for (const type of heritageClause.types) { + // const candidate = await (isExpressionWithTypeArguments(type) ? this.getLeafSymbolAtLocation(type.expression) : this.getLeafSymbolAtLocation(type.typeName)); + const candidate = await this.getLeafSymbolAtLocation(type.expression); + if (candidate === undefined) { + continue; + } + // const name = isExpressionWithTypeArguments(type) ? type.expression.getText() : type.typeName.getText(); + const name = type.expression.getText(); + if (heritageClause.token === SyntaxKind.ExtendsKeyword && result.extends === undefined) { + result.extends = { symbol: candidate, name }; + } else if (heritageClause.token === SyntaxKind.ImplementsKeyword) { + (result.implements ??= []).push({ symbol: candidate, name }); + } + } + } + } + return result.extends === undefined && result.implements === undefined ? undefined : result; + } + + public async getAllSuperTypes(symbol: NativeSymbol): Promise { + return this.getAllSuperSymbols(symbol); + } + + public async getAllSuperClasses(symbol: NativeSymbol): Promise { + return (await this.getAllSuperSymbols(symbol)).filter(candidate => Symbols.isClass(candidate)); + } + + public async getAllSuperSymbols(symbol: NativeSymbol): Promise { + const result: NativeSymbol[] = []; + const seen = new Set([symbol.id]); + const queue: NativeSymbol[] = [symbol]; + while (queue.length > 0) { + this.token.throwIfCancellationRequested(); + const current = queue.shift(); + if (current === undefined) { + break; + } + const direct = await this.getDirectSuperSymbols(current); + const candidates = direct === undefined ? [] : [direct.extends?.symbol, ...(direct.implements?.map(item => item.symbol) ?? [])]; + for (const candidate of candidates) { + if (candidate === undefined || seen.has(candidate.id)) { + continue; + } + seen.add(candidate.id); + result.push(candidate); + queue.push(candidate); + } + } + return result; + } + + public async getTypeSymbols(type: Type): Promise { + const result: NativeSymbol[] = []; + await this.collectTypeSymbols(result, new Set(), type); + return result; + } + + public async createKey(symbol: NativeSymbol): Promise; + public async createKey(declaration: DeclarationBase): Promise; + public async createKey(arg: NativeSymbol | DeclarationBase): Promise + { + if (arg instanceof NativeSymbol) { + const symbol = arg; + const declarations = await this.getDeclarations(symbol); + if (declarations.length === 0) { + return undefined; + } + const fragments = declarations.map(declaration => ({ + f: declaration.getSourceFile().path, + s: declaration.getStart(), + e: declaration.getEnd(), + k: declaration.kind, + })).sort((first, second) => first.f.localeCompare(second.f) || first.s - second.s || first.e - second.e || first.k - second.k); + const hash = createHash('md5'); // CodeQL [SM04514] Used only as a compact cache key, not for security. + if ((symbol.flags & SymbolFlags.Transient) !== 0) { + hash.update(JSON.stringify({ trans: true })); + } + hash.update(JSON.stringify(fragments)); + return hash.digest('base64'); + } else { + const declaration = arg; + const fragment = { + f: declaration.getSourceFile().path, + s: declaration.getStart(), + e: declaration.getEnd(), + k: declaration.kind, + }; + const hash = createHash('md5'); // CodeQL [SM04514] Used only as a compact cache key, not for security. + hash.update(JSON.stringify(fragment)); + return hash.digest('base64'); + } + } + + public async getDeclaration(symbol: NativeSymbol, kind: SyntaxKind): Promise { + return (await this.getDeclarations(symbol)).find(declaration => declaration.kind === kind) as T | undefined; + } + + public static isFunctionScopedVariable(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.FunctionScopedVariable) !== 0; + } + + public static isBlockScopedVariable(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.BlockScopedVariable) !== 0; + } + + public static isConstructor(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Constructor) !== 0; + } + + public static isMethod(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Method) !== 0; + } + + public static isProperty(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Property) !== 0; + } + + public static isClass(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Class) !== 0; + } + + public static isInterface(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Interface) !== 0; + } + + public static isTypeAlias(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.TypeAlias) !== 0; + } + + public static isTypeParameter(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.TypeParameter) !== 0; + } + + public static isTypeLiteral(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.TypeLiteral) !== 0; + } + + public static isEnum(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & (SymbolFlags.RegularEnum | SymbolFlags.ConstEnum)) !== 0; + } + + public static isFunction(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Function) !== 0; + } + + public static isValueModule(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.ValueModule) !== 0; + } + + public static isAlias(symbol: NativeSymbol | undefined): symbol is NativeSymbol { + return symbol !== undefined && (symbol.flags & SymbolFlags.Alias) !== 0; + } + + public static isInternal(symbol: NativeSymbol): boolean { + return symbol.name === '__type' || symbol.name === '__class' || symbol.name === '__object'; + } + + private async collectTypeSymbols(result: NativeSymbol[], seen: Set, type: Type): Promise { + this.token.throwIfCancellationRequested(); + const alias = await type.getAliasSymbol(); + const symbol = alias ?? await type.getSymbol(); + if (symbol !== undefined) { + const leaf = await this.getLeafSymbol(symbol); + if (!seen.has(leaf.id)) { + seen.add(leaf.id); + result.push(leaf); + } + return; + } + if (type.isUnionType() || type.isIntersectionType()) { + for (const item of await type.getTypes()) { + await this.collectTypeSymbols(result, seen, item); + } + } + } + + private async resolveDeclarations(handles: readonly NodeHandle[]): Promise { + const result: Node[] = []; + for (const handle of handles) { + this.token.throwIfCancellationRequested(); + const declaration = await handle.resolve(this.project); + this.token.throwIfCancellationRequested(); + if (declaration !== undefined) { + result.push(declaration); + } + } + return result; + } +} + +export namespace Types { + export function isIntersection(type: Type): boolean { + return type.isIntersectionType(); + } + + export function isUnion(type: Type): boolean { + return type.isUnionType(); + } +} + +export default tss; diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/tsContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsContextService.ts new file mode 100644 index 00000000000000..ecc53b2a05ae8b --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsContextService.ts @@ -0,0 +1,795 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +import { LRUCache } from 'lru-cache'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { ILanguageContextService, type ContextItem, type RequestContext } from '../../../platform/languageServer/common/languageContextService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; +import { DisposableStore } from '../../../util/vs/base/common/lifecycle'; +import * as protocol from '../common/serverProtocol'; +import { CacheState, ContextItemUsageMode, ResolvedRunnableResult, type CacheInfo, type OnCachePopulatedEvent, type OnContextComputedEvent, type OnContextComputedOnTimeoutEvent } from './types'; +import { TelemetrySender } from './telemetrySender'; + +export const currentTokenBudget: number = 8 * 1024; + +type RequestInfo = { + readonly document: string; + readonly version: number; + readonly languageId: string; + readonly position: vscode.Position; + readonly requestId: string; + readonly path: number[]; +}; + +type ContextRequestState = { + client: readonly ResolvedRunnableResult[]; + clientOnTimeout: readonly ResolvedRunnableResult[]; + server: readonly protocol.CachedContextRunnableResult[]; + resultMap: Map; + itemMap: Map; +}; + +type ManagerUpdateResult = { + resolved: ResolvedRunnableResult[]; + serverComputed: Set; + cached: number; + referenced: number; +}; + +class RunnableResultManager implements vscode.Disposable { + + private readonly disposables = new DisposableStore(); + private requestInfo: RequestInfo | undefined; + + private cacheInfo: CacheInfo; + private results: Map; + private readonly withInRangeRunnableResults: { resultId: protocol.ContextRunnableResultId; range: vscode.Range }[]; + private readonly outsideRangeRunnableResults: { resultId: protocol.ContextRunnableResultId; ranges: vscode.Range[] }[] = []; + private readonly neighborFileRunnableResults: { resultId: protocol.ContextRunnableResultId }[]; + + constructor() { + this.requestInfo = undefined; + this.results = new Map(); + + this.cacheInfo = { + version: 0, + state: CacheState.NotPopulated + }; + this.withInRangeRunnableResults = []; + this.outsideRangeRunnableResults = []; + this.neighborFileRunnableResults = []; + + this.disposables.add(vscode.workspace.onDidChangeTextDocument((event: vscode.TextDocumentChangeEvent) => { + if (this.requestInfo === undefined || event.contentChanges.length === 0) { + return; + } + if (event.document.uri.toString() !== this.requestInfo.document) { + if (this.affectsTypeScript(event)) { + this.clear(); + } + } else { + for (const change of event.contentChanges) { + const changeRange = change.range; + for (let i = 0; i < this.withInRangeRunnableResults.length;) { + const entry = this.withInRangeRunnableResults[i]; + if (entry.range.contains(changeRange)) { + entry.range = this.applyTextContentChangeEventToWithinRange(change, entry.range); + i++; + } else { + const id = entry.resultId; + this.results.delete(id); + this.withInRangeRunnableResults.splice(i, 1); + } + } + for (let i = 0; i < this.outsideRangeRunnableResults.length;) { + const entry = this.outsideRangeRunnableResults[i]; + const ranges = this.applyTextContentChangeEventToOutsideRanges(change, entry.ranges); + if (ranges === undefined) { + const id = entry.resultId; + this.results.delete(id); + this.outsideRangeRunnableResults.splice(i, 1); + } else { + entry.ranges = ranges; + i++; + } + } + this.cacheInfo.version = event.document.version; + } + } + })); + this.disposables.add(vscode.workspace.onDidCloseTextDocument((document: vscode.TextDocument) => { + if (this.requestInfo?.document === document.uri.toString()) { + this.clear(); + } + })); + this.disposables.add(vscode.window.onDidChangeActiveTextEditor(() => { + this.clear(); + })); + this.disposables.add(vscode.window.tabGroups.onDidChangeTabs((event: vscode.TabChangeEvent) => { + if (event.closed.length === 0 && event.opened.length === 0) { + return; + } + for (const item of this.neighborFileRunnableResults) { + this.results.delete(item.resultId); + } + this.neighborFileRunnableResults.length = 0; + })); + } + + public clear(): void { + this.requestInfo = undefined; + this.results.clear(); + + this.cacheInfo = { + version: 0, + state: CacheState.NotPopulated + }; + this.withInRangeRunnableResults.length = 0; + this.outsideRangeRunnableResults.length = 0; + this.neighborFileRunnableResults.length = 0; + } + + public getCacheState(): CacheState { + return this.cacheInfo.state; + } + + public update(document: vscode.TextDocument, version: number, position: vscode.Position, context: RequestContext, body: protocol.ComputeContextResponse.OK, requestState: ContextRequestState | undefined): ManagerUpdateResult { + const itemMap = requestState?.itemMap ?? new Map(); + const usedResults = requestState?.resultMap ?? new Map(); + + this.withInRangeRunnableResults.length = 0; + this.outsideRangeRunnableResults.length = 0; + this.neighborFileRunnableResults.length = 0; + this.results.clear(); + this.cacheInfo = { + version: version, + state: CacheState.NotPopulated + }; + + let cachedItems = 0; + let referencedItems = 0; + const serverComputed: Set = new Set(); + this.requestInfo = { + document: document.uri.toString(), + version: version, + languageId: document.languageId, + position: position, + requestId: context.requestId, + path: body.path ?? [0] + }; + + if (body.runnableResults === undefined || body.runnableResults.length === 0 || body.path === undefined || body.path.length === 0 || body.path[0] === 0) { + return { resolved: [], cached: cachedItems, referenced: referencedItems, serverComputed: serverComputed }; + } + + const serverItems: Set = new Set(); + // Add new client side context items to the item map. + if (body.contextItems !== undefined && body.contextItems.length > 0) { + for (const item of body.contextItems) { + if (protocol.ContextItem.hasKey(item)) { + itemMap.set(item.key, item); + serverItems.add(item.key); + } + } + } + const updateRunnableResult = (resultItem: protocol.ContextRunnableResultTypes): ResolvedRunnableResult | undefined => { + let result: ResolvedRunnableResult | undefined; + if (resultItem.kind === protocol.ContextRunnableResultKind.ComputedResult) { + serverComputed.add(resultItem.id); + const items: protocol.FullContextItem[] = []; + for (const contextItem of resultItem.items) { + if (contextItem.kind === protocol.ContextKind.Reference) { + const referenced: protocol.FullContextItem | undefined = itemMap.get(contextItem.key); + if (referenced !== undefined) { + referencedItems++; + items.push(referenced); + if (!serverItems.has(contextItem.key)) { + cachedItems++; + } + } + } else { + items.push(contextItem); + } + } + result = ResolvedRunnableResult.from(resultItem, items); + } else if (resultItem.kind === protocol.ContextRunnableResultKind.Reference) { + result = usedResults.get(resultItem.id); + if (result !== undefined) { + cachedItems += result.items.length; + } + } + if (result === undefined) { + return; + } + this.results.set(result.id, result); + if (result.cache !== undefined) { + if (result.cache.scope.kind === protocol.CacheScopeKind.WithinRange) { + const scopeRange = result.cache.scope.range; + const range = new vscode.Range(scopeRange.start.line, scopeRange.start.character, scopeRange.end.line, scopeRange.end.character); + this.withInRangeRunnableResults.push({ range, resultId: result.id }); + } else if (result.cache.scope.kind === protocol.CacheScopeKind.NeighborFiles) { + this.neighborFileRunnableResults.push({ resultId: result.id }); + } else if (result.cache.scope.kind === protocol.CacheScopeKind.OutsideRange) { + const ranges: vscode.Range[] = []; + for (const scopeRange of result.cache.scope.ranges) { + ranges.push(new vscode.Range(scopeRange.start.line, scopeRange.start.character, scopeRange.end.line, scopeRange.end.character)); + } + this.outsideRangeRunnableResults.push({ resultId: result.id, ranges }); + } + } + this.updateCacheState(result.state); + return result; + }; + + const results: ResolvedRunnableResult[] = []; + for (const runnableResult of body.runnableResults) { + const result = updateRunnableResult(runnableResult); + if (result !== undefined) { + results.push(result); + } + } + return { resolved: results, cached: cachedItems, referenced: referencedItems, serverComputed: serverComputed }; + } + + private updateCacheState(state: protocol.ContextRunnableState): void { + switch (this.cacheInfo.state) { + case CacheState.NotPopulated: + switch (state) { + case protocol.ContextRunnableState.Finished: + this.cacheInfo.state = CacheState.FullyPopulated; + break; + case protocol.ContextRunnableState.IsFull: + case protocol.ContextRunnableState.InProgress: + this.cacheInfo.state = CacheState.PartiallyPopulated; + break; + default: + this.cacheInfo.state = CacheState.NotPopulated; + } + break; + case CacheState.PartiallyPopulated: + // If the cache is partially populated we can only stay in that state. + break; + case CacheState.FullyPopulated: + switch (state) { + case protocol.ContextRunnableState.Finished: + // If the cache is fully populated we can only stay in that state. + break; + case protocol.ContextRunnableState.IsFull: + case protocol.ContextRunnableState.InProgress: + this.cacheInfo.state = CacheState.PartiallyPopulated; + break; + default: + this.cacheInfo.state = CacheState.NotPopulated; + } + break; + } + } + + public getRequestId(): string | undefined { + return this.requestInfo?.requestId; + } + + public getNodePath(): number[] { + return this.requestInfo?.path ?? [0]; + } + + public getRunnableResult(id: protocol.ContextRunnableResultId): ResolvedRunnableResult | undefined { + return this.results.get(id); + } + + public getCachedRunnableResults(document: vscode.TextDocument, position: vscode.Position, emitMode?: protocol.EmitMode): ResolvedRunnableResult[] { + const results: ResolvedRunnableResult[] = []; + if (this.requestInfo?.document !== document.uri.toString()) { + return results; + } + if (this.cacheInfo.version !== document.version || this.cacheInfo.state === CacheState.NotPopulated || this.requestInfo.path.length === 0 || this.requestInfo.path[0] === 0) { + return results; + } + for (const item of this.results.values()) { + if (emitMode !== undefined && item.cache?.emitMode === emitMode) { + continue; + } + const scope = item.cache?.scope; + if (scope === undefined || scope.kind !== protocol.CacheScopeKind.WithinRange) { + results.push(item); + } else { + const r = scope.range; + const range = new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character); + if (range.contains(position)) { + results.push(item); + } + } + } + // Sort them by priority so that the most important items are emitted first if they + // are contained in more than one runnable result. + return results.sort((a, b) => { + return a.priority < b.priority ? 1 : a.priority > b.priority ? -1 : 0; + }); + } + + public getContextRequestState(document: vscode.TextDocument, position: vscode.Position): ContextRequestState | undefined { + if (this.requestInfo?.document !== document.uri.toString()) { + return undefined; + } + if (this.cacheInfo.version !== document.version || this.cacheInfo.state === CacheState.NotPopulated || this.requestInfo.path.length === 0 || this.requestInfo.path[0] === 0) { + return undefined; + } + const items: Map = new Map(); + const client: ResolvedRunnableResult[] = []; + const clientOnTimeout: ResolvedRunnableResult[] = []; + const server: protocol.CachedContextRunnableResult[] = []; + if (this.isCacheFullyUpToDate(document, position)) { + for (const item of this.results.values()) { + client.push(item); + } + } else { + const canSkipItems = (rr: ResolvedRunnableResult, cache: protocol.CacheInfo): boolean => { + if (rr.state === protocol.ContextRunnableState.Finished) { + return true; + } + if (rr.state === protocol.ContextRunnableState.IsFull) { + const kind = cache.scope.kind; + return kind === protocol.CacheScopeKind.WithinRange || kind === protocol.CacheScopeKind.NeighborFiles || kind === protocol.CacheScopeKind.File; + } + return false; + }; + const handleRunnableResult = (id: string, rr: ResolvedRunnableResult) => { + const cache = rr.cache; + const cachedResult: protocol.CachedContextRunnableResult = { + id: id, + kind: protocol.ContextRunnableResultKind.CacheEntry, + state: rr.state, + items: [] + }; + let skipItems = false; + if (cache !== undefined) { + cachedResult.cache = cache; + const emitMode = cache.emitMode; + if (emitMode === protocol.EmitMode.ClientBased) { + client.push(rr); + skipItems = canSkipItems(rr, cache); + } else if (emitMode === protocol.EmitMode.ClientBasedOnTimeout) { + clientOnTimeout.push(rr); + } + } + server.push(cachedResult); + + if (skipItems) { + return; + } + + // Add cached context items to the result; + for (const item of rr.items) { + if (!protocol.ContextItem.hasKey(item)) { + continue; + } + const key = item.key; + let size: number | undefined = undefined; + switch (item.kind) { + case protocol.ContextKind.Snippet: + size = protocol.CodeSnippet.sizeInChars(item); + break; + case protocol.ContextKind.Trait: + size = protocol.Trait.sizeInChars(item); + break; + default: + } + cachedResult.items.push(protocol.CachedContextItem.create(key, size)); + items.set(key, item); + } + }; + // We don't need to sort by priority here since the data is used for the next cache request. + for (const [id, item] of this.results.entries()) { + const scope = item.cache?.scope; + if (scope === undefined || scope.kind !== protocol.CacheScopeKind.WithinRange) { + handleRunnableResult(id, item); + } else { + const r = scope.range; + const range = new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character); + if (range.contains(position)) { + handleRunnableResult(id, item); + } + } + } + } + return { client, clientOnTimeout, server, itemMap: items, resultMap: new Map(this.results) }; + } + + private isCacheFullyUpToDate(document: vscode.TextDocument, position: vscode.Position): boolean { + if (this.requestInfo === undefined) { + return false; + } + if (this.requestInfo.document !== document.uri.toString()) { + return false; + } + + // Same document, version and position. Cache can be full used. + if (this.requestInfo.version === document.version && this.requestInfo.position.isEqual(position)) { + return true; + } + + // Document is older than cached request. Not up to date. + if (this.requestInfo.version > document.version) { + return false; + } + + // if the position is not contained in all ranges return false. + for (const runnable of this.withInRangeRunnableResults) { + if (!runnable.range.contains(position)) { + return false; + } + } + + const range = position.isBefore(this.requestInfo.position) ? new vscode.Range(position, this.requestInfo.position) : new vscode.Range(this.requestInfo.position, position); + const text = document.getText(range); + return text.trim().length === 0; + } + + public dispose(): void { + this.clear(); + this.disposables.dispose(); + } + + private affectsTypeScript(event: vscode.TextDocumentChangeEvent): boolean { + const languageId = event.document.languageId; + return languageId === 'typescript' || languageId === 'typescriptreact' || languageId === 'javascript' || languageId === 'javascriptreact' || languageId === 'json'; + } + + private applyTextContentChangeEventToWithinRange(event: vscode.TextDocumentContentChangeEvent, range: vscode.Range): vscode.Range { + // The start stays untouched since the change range is contained in the range. + const eventRange = event.range; + const eventText = event.text; + + // Calculate how many lines the new text adds or removes + const linesDelta = (eventText.match(/\n/g) || []).length - (eventRange.end.line - eventRange.start.line); + + // Calculate the new end position + const endLine = range.end.line + linesDelta; + + let endCharacter = range.end.character; + if (eventRange.end.line === range.end.line) { + // Calculate the character delta for the last line of the change + const lastNewLineIndex = eventText.lastIndexOf('\n'); + const newTextLength = lastNewLineIndex !== -1 ? eventText.length - lastNewLineIndex - 1 : eventText.length; + const oldTextLength = eventRange.end.character - (eventRange.end.line > eventRange.start.line ? 0 : eventRange.start.character); + const charDelta = newTextLength - oldTextLength; + endCharacter += charDelta; + } + return new vscode.Range(range.start, new vscode.Position(endLine, endCharacter)); + } + + private applyTextContentChangeEventToOutsideRanges(event: vscode.TextDocumentContentChangeEvent, ranges: vscode.Range[]): vscode.Range[] | undefined { + if (ranges.length === 0) { + return ranges; + } + const changeRange = event.range; + const eventText = event.text; + + // Quick optimization: if change is completely after last range, no ranges need adjustment + const lastRange = ranges[ranges.length - 1]; + if (changeRange.start.isAfter(lastRange.end)) { + return ranges; + } + // Calculate how many lines the new text adds or removes + const linesDelta = (eventText.match(/\n/g) || []).length - (changeRange.end.line - changeRange.start.line); + const adjustedRanges: vscode.Range[] = []; + + for (const range of ranges) { + if (range.end.isBefore(changeRange.start)) { + // Range is completely before change, no adjustment needed + adjustedRanges.push(range); + } else if (range.start.isAfter(changeRange.end)) { + // Range is completely after change, adjust by lines delta + if (linesDelta === 0) { + adjustedRanges.push(range); + } else { + adjustedRanges.push(new vscode.Range( + new vscode.Position(range.start.line + linesDelta, range.start.character), + new vscode.Position(range.end.line + linesDelta, range.end.character) + )); + } + } else { + + // The range intersects with the range with will invalidate the cache entry. + return undefined; + } + } + + return adjustedRanges; + } +} + +namespace TextDocuments { + export function consider(document: vscode.TextDocument): boolean { + return document.uri.scheme === 'file' && (document.languageId === 'typescript' || document.languageId === 'typescriptreact'); + } +} + +class NeighborFileModel implements vscode.Disposable { + + private static readonly MAX_ITEMS = 12; + + private readonly disposables; + private readonly visible: LRUCache; + private readonly notVisible: LRUCache; + + constructor() { + this.disposables = new DisposableStore(); + this.visible = new LRUCache({ max: NeighborFileModel.MAX_ITEMS }); + this.notVisible = new LRUCache({ max: NeighborFileModel.MAX_ITEMS }); + this.disposables.add(vscode.window.onDidChangeActiveTextEditor((editor: vscode.TextEditor | undefined) => { + if (editor === undefined) { + return; + } + const document = editor.document; + if (TextDocuments.consider(document)) { + const uri = document.uri.toString(); + this.visible.set(uri, document.uri.fsPath); + this.notVisible.delete(uri); + } + })); + this.disposables.add(vscode.workspace.onDidCloseTextDocument((document: vscode.TextDocument) => { + const uri = document.uri.toString(); + if (TextDocuments.consider(document)) { + this.visible.delete(uri); + this.notVisible.delete(uri); + } + })); + this.disposables.add(vscode.window.tabGroups.onDidChangeTabs((e: vscode.TabChangeEvent) => { + // We don't track open tabs here to ensure we only track documents that are + // actually focused. Otherwise opening multiple tabs at once would cause too much churn. + for (const tab of e.closed) { + if (tab.input instanceof vscode.TabInputText) { + const uri = tab.input.uri.toString(); + const isVisible = this.visible.has(uri); + if (isVisible) { + this.visible.delete(uri); + this.notVisible.set(uri, tab.input.uri.fsPath); + } + } + } + })); + const textDocumentsToConsider: Map = new Map(); + for (const document of vscode.workspace.textDocuments) { + if (TextDocuments.consider(document)) { + textDocumentsToConsider.set(document.uri.toString(), document.uri); + } + } + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const uri = tab.input instanceof vscode.TabInputText ? tab.input.uri : undefined; + if (uri !== undefined && textDocumentsToConsider.has(uri.toString())) { + this.visible.set(uri.toString(), uri.fsPath); + textDocumentsToConsider.delete(uri.toString()); + } + } + } + for (const [key, uri] of textDocumentsToConsider.entries()) { + this.notVisible.set(key, uri.fsPath); + } + if (vscode.window.activeTextEditor !== undefined) { + const document = vscode.window.activeTextEditor.document; + if (TextDocuments.consider(document)) { + const uri = document.uri.toString(); + this.visible.set(uri, document.uri.fsPath); + this.notVisible.delete(uri); + } + } + } + + public getNeighborFiles(currentDocument: vscode.TextDocument): string[] { + const result: string[] = []; + const currentUri = currentDocument.uri.toString(); + for (const [key, value] of this.visible.entries()) { + if (key === currentUri) { + continue; + } + result.push(value); + } + if (result.length < NeighborFileModel.MAX_ITEMS) { + for (const [key, value] of this.notVisible.entries()) { + if (key === currentUri) { + continue; + } + result.push(value); + if (result.length >= NeighborFileModel.MAX_ITEMS) { + break; + } + } + } + return result; + } + + public dispose(): void { + this.disposables.dispose(); + } +} + +class CharacterBudget { + + public readonly overall: number; + private mandatory: number; + private optional: number; + private start: { mandatory: number; optional: number }; + + constructor(mandatory: number, optional: number) { + this.overall = mandatory; + this.mandatory = mandatory; + this.optional = optional; + this.start = { mandatory, optional }; + } + + spend(chars: number): void { + this.mandatory -= chars; + this.optional -= chars; + } + + isExhausted(): boolean { + return this.mandatory <= 0; + } + + isOptionalExhausted(): boolean { + return this.optional <= 0; + } + + public fresh(): CharacterBudget { + return new CharacterBudget(this.start.mandatory, this.start.optional); + } +} + +export interface TSLanguageContextService extends Omit, vscode.Disposable { + readonly onCachePopulated: vscode.Event; + readonly onContextComputed: vscode.Event; + readonly onContextComputedOnTimeout: vscode.Event; +} + +export abstract class AbstractTSLanguageContextService implements TSLanguageContextService { + + private static readonly defaultCachePopulationBudget: number = 500; + + protected readonly disposables: DisposableStore; + protected readonly telemetrySender: TelemetrySender; + protected readonly neighborFileModel: NeighborFileModel; + protected readonly runnableResultManager: RunnableResultManager; + protected readonly logService: ILogService; + protected readonly configurationService: IConfigurationService; + protected readonly experimentationService: IExperimentationService; + + protected usageMode: ContextItemUsageMode; + protected cachePopulationTimeout: number; + protected includeDocumentation: boolean; + + + protected _onCachePopulated: vscode.EventEmitter; + public readonly onCachePopulated: vscode.Event; + + protected _onContextComputed: vscode.EventEmitter; + public readonly onContextComputed: vscode.Event; + + protected _onContextComputedOnTimeout: vscode.EventEmitter; + public readonly onContextComputedOnTimeout: vscode.Event; + + constructor( + telemetryService: ITelemetryService, + logService: ILogService, + configurationService: IConfigurationService, + experimentationService: IExperimentationService + ) { + this.disposables = new DisposableStore(); + + this.configurationService = configurationService; + this.experimentationService = experimentationService; + this.logService = logService; + this.telemetrySender = new TelemetrySender(telemetryService, logService); + this.neighborFileModel = this.disposables.add(new NeighborFileModel()); + this.runnableResultManager = this.disposables.add(new RunnableResultManager()); + + this.usageMode = this.getUsageMode(); + this.cachePopulationTimeout = this.getCachePopulationBudget(); + this.includeDocumentation = this.getIncludeDocumentation(); + + this.disposables.add(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextMode.fullyQualifiedId)) { + this.usageMode = this.getUsageMode(); + } else if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextCacheTimeout.fullyQualifiedId)) { + this.cachePopulationTimeout = this.getCachePopulationBudget(); + } else if (e.affectsConfiguration(ConfigKey.TypeScriptLanguageContextIncludeDocumentation.fullyQualifiedId)) { + this.includeDocumentation = this.getIncludeDocumentation(); + } + })); + + + this._onCachePopulated = this.disposables.add(new vscode.EventEmitter()); + this.onCachePopulated = this._onCachePopulated.event; + + this._onContextComputed = this.disposables.add(new vscode.EventEmitter()); + this.onContextComputed = this._onContextComputed.event; + + this._onContextComputedOnTimeout = this.disposables.add(new vscode.EventEmitter()); + this.onContextComputedOnTimeout = this._onContextComputedOnTimeout.event; + } + + public dispose(): void { + this.disposables.dispose(); + } + + public abstract isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise; + + public abstract populateCache(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): Promise; + + public abstract getContext(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, token: vscode.CancellationToken): AsyncIterable; + + public abstract getContextOnTimeout(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): readonly ContextItem[] | undefined; + + private getCachePopulationBudget(): number { + const result = this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextCacheTimeout, this.experimentationService); + return result ?? AbstractTSLanguageContextService.defaultCachePopulationBudget; + } + + private getUsageMode(): ContextItemUsageMode { + const value = this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextMode, this.experimentationService); + return ContextItemUsageMode.fromString(value); + } + + private getIncludeDocumentation(): boolean { + return this.configurationService.getExperimentBasedConfig(ConfigKey.TypeScriptLanguageContextIncludeDocumentation, this.experimentationService); + } + + protected getCharacterBudget(context: RequestContext, document: vscode.TextDocument): CharacterBudget { + const chars = (context.tokenBudget ?? currentTokenBudget) * 4; + switch (this.usageMode) { + case ContextItemUsageMode.minimal: + return new CharacterBudget(chars, 0); + case ContextItemUsageMode.double: + return new CharacterBudget(chars, Math.min(chars, document.getText().length)); + case ContextItemUsageMode.fillHalf: + return new CharacterBudget(chars, Math.floor(chars / 2)); + case ContextItemUsageMode.fill: + return new CharacterBudget(chars, chars); + default: + return new CharacterBudget(chars, chars); + } + } +} + +export class NullTSLanguageContextService implements TSLanguageContextService { + + private readonly disposables: DisposableStore; + + public readonly onCachePopulated: vscode.Event; + public readonly onContextComputed: vscode.Event; + public readonly onContextComputedOnTimeout: vscode.Event; + + constructor() { + this.disposables = new DisposableStore(); + this.onCachePopulated = this.disposables.add(new vscode.EventEmitter()).event; + this.onContextComputed = this.disposables.add(new vscode.EventEmitter()).event; + this.onContextComputedOnTimeout = this.disposables.add(new vscode.EventEmitter()).event; + } + + public dispose(): void { + this.disposables.dispose(); + } + + public async isActivated(): Promise { + return false; + } + + public async populateCache(): Promise { + // No cache to populate + } + + public getContext(): AsyncIterable { + return (async function* () { })(); + } + + public getContextOnTimeout(): readonly ContextItem[] | undefined { + return undefined; + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/tsService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsService.ts new file mode 100644 index 00000000000000..00d2d23cef0717 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsService.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; + +export namespace TypeScript { + + const unifiedSection = 'js/ts'; + const legacySection = 'typescript'; + const useTsgoKey = 'experimental.useTsgo'; + const version7ExtensionIds = ['typescriptteam.vscode-typescript', 'typescriptteam.native-preview'] as const; + + export const versionKey = `${unifiedSection}.${useTsgoKey}`; + export const legacyVersionKey = `${legacySection}.${useTsgoKey}`; + + export function runsVersion7(): boolean { + // Mirrors `readUnifiedConfig` in the TypeScript extension: the unified setting wins whenever the user set it, + // otherwise the deprecated `typescript.experimental.useTsgo` still applies. + const unified = vscode.workspace.getConfiguration(unifiedSection); + if (hasUserValue(unified.inspect(useTsgoKey))) { + return unified.get(useTsgoKey, false) === true; + } + return vscode.workspace.getConfiguration(legacySection).get(useTsgoKey, false) === true; + } + + export function affectsVersion(e: vscode.ConfigurationChangeEvent): boolean { + return e.affectsConfiguration(versionKey) || e.affectsConfiguration(legacyVersionKey); + } + + export function isVersion7SupportEnabled(configurationService: IConfigurationService): boolean { + return configurationService.getConfig(ConfigKey.TypeScript7LanguageContext) ?? false; + } + + export function getVersion7Extension(getExtension: (extensionId: string) => vscode.Extension | undefined = extensionId => vscode.extensions.getExtension(extensionId)): vscode.Extension | undefined { + for (const extensionId of version7ExtensionIds) { + const extension = getExtension(extensionId); + if (extension !== undefined) { + return extension; + } + } + return undefined; + } + + function hasUserValue(inspect: ReturnType): boolean { + return inspect !== undefined && ( + inspect.globalValue !== undefined || + inspect.workspaceValue !== undefined || + inspect.workspaceFolderValue !== undefined || + inspect.globalLanguageValue !== undefined || + inspect.workspaceLanguageValue !== undefined || + inspect.workspaceFolderLanguageValue !== undefined + ); + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/nesRenameService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/nesRenameService.ts new file mode 100644 index 00000000000000..be4d7b32022324 --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/nesRenameService.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import * as protocol from '../../common/serverProtocol'; + +enum ExecutionTarget { + Semantic, + Syntax +} + +type ExecConfig = { + readonly lowPriority?: boolean; + readonly nonRecoverable?: boolean; + readonly cancelOnResourceChange?: vscode.Uri; + readonly executionTarget?: ExecutionTarget; +}; + +type PrepareNesRenameRequestArgs = Omit & { + file: vscode.Uri; + line: number; + offset: number; +}; + +namespace PrepareNesRenameRequestArgs { + export function create(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, startTime: number, timeBudget: number): PrepareNesRenameRequestArgs { + return { + file: vscode.Uri.file(document.fileName), + line: position.line + 1, + offset: position.character + 1, + oldName, + newName, + lastSymbolRename: lastSymbolRename ? { + start: { line: lastSymbolRename.start.line + 1, character: lastSymbolRename.start.character + 1 }, + end: { line: lastSymbolRename.end.line + 1, character: lastSymbolRename.end.character + 1 }, + } : undefined, + startTime, + timeBudget, + }; + } +} + +type NesRenameRequestArgs = Omit & { + file: vscode.Uri; + line: number; + offset: number; +}; + +namespace NesRenameRequestArgs { + export function create(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined): NesRenameRequestArgs { + return { + file: vscode.Uri.file(document.fileName), + line: position.line + 1, + offset: position.character + 1, + oldName, + newName, + lastSymbolRename: lastSymbolRename ? { + start: { line: lastSymbolRename.start.line + 1, character: lastSymbolRename.start.character + 1 }, + end: { line: lastSymbolRename.end.line + 1, character: lastSymbolRename.end.character + 1 }, + } : undefined, + }; + } +} + +export class TS6NesRenameService implements vscode.Disposable { + private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic }; + + private isActivatedPromise: Promise | undefined; + + constructor(private readonly logService: ILogService) { } + + public dispose(): void { } + + public async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; + if (languageId !== 'typescript' && languageId !== 'typescriptreact') { + return false; + } + this.isActivatedPromise ??= this.doIsTypeScriptActivated(); + return this.isActivatedPromise; + } + + public async prepare(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, startTime: number, timeBudget: number, token: vscode.CancellationToken): Promise { + const args = PrepareNesRenameRequestArgs.create(document, position, oldName, newName, lastSymbolRename, startTime, timeBudget); + const response = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.prepareNesRename', args, TS6NesRenameService.ExecConfig, token); + if (protocol.PrepareNesRenameResponse.isError(response)) { + return response.body; + } + if (protocol.PrepareNesRenameResponse.isOk(response)) { + return response.body; + } + return { canRename: protocol.RenameKind.no, timedOut: false }; + } + + public async postRename(document: vscode.TextDocument, position: vscode.Position, oldName: string, newName: string, lastSymbolRename: vscode.Range | undefined, token: vscode.CancellationToken): Promise { + const args = NesRenameRequestArgs.create(document, position, oldName, newName, lastSymbolRename); + const response = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.postNesRename', args, TS6NesRenameService.ExecConfig, token); + return protocol.NesRenameResponse.isOk(response) ? response.body.groups : []; + } + + private async doIsTypeScriptActivated(): Promise { + try { + const typeScriptExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); + if (typeScriptExtension === undefined) { + return false; + } + await typeScriptExtension.activate(); + + const response: protocol.PingResponse | undefined = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.ping', TS6NesRenameService.ExecConfig, CancellationToken.None); + if (response?.body?.kind === 'ok') { + this.logService.info('TypeScript server plugin activated.'); + return true; + } + const message = response === undefined ? 'No ping response received.' : response.body?.message ?? 'Message not provided.'; + this.logService.error('TypeScript server plugin not activated:', message); + } catch (error) { + this.logService.error('Error pinging TypeScript server plugin:', error); + } + return false; + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/tsContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/tsContextService.ts new file mode 100644 index 00000000000000..0ecd763121e5da --- /dev/null +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/tsc6/tsContextService.ts @@ -0,0 +1,449 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +import { IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import { type ContextItem, type RequestContext, KnownSources } from '../../../../platform/languageServer/common/languageContextService'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; +import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import * as protocol from '../../common/serverProtocol'; +import { ContextItemResultBuilder, ResolvedRunnableResult } from '../types'; +import { currentTokenBudget, AbstractTSLanguageContextService } from '../tsContextService'; + +enum ExecutionTarget { + Semantic, + Syntax +} + +type ExecConfig = { + readonly lowPriority?: boolean; + readonly nonRecoverable?: boolean; + readonly cancelOnResourceChange?: vscode.Uri; + readonly executionTarget?: ExecutionTarget; +}; + +type ComputeContextRequestArgs = Omit & { + file: vscode.Uri; + line: number; + offset: number; + $traceId?: string; +}; + +namespace ComputeContextRequestArgs { + export function create(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, startTime: number, timeBudget: number, willLogRequestTelemetry: boolean, neighborFiles: readonly string[] | undefined, clientSideRunnableResults: readonly protocol.CachedContextRunnableResult[] | undefined, includeDocumentation: boolean): ComputeContextRequestArgs { + return { + file: vscode.Uri.file(document.fileName), + line: position.line + 1, + offset: position.character + 1, + startTime: startTime, + timeBudget: timeBudget, + primaryCharacterBudget: (context.tokenBudget ?? 7 * 1024) * 4, + secondaryCharacterBudget: (currentTokenBudget * 4), + includeDocumentation: includeDocumentation, + neighborFiles: neighborFiles !== undefined && neighborFiles.length > 0 ? neighborFiles : undefined, + clientSideRunnableResults: clientSideRunnableResults, + $traceId: willLogRequestTelemetry ? context.requestId : undefined + }; + } +} + +class PendingRequestInfo { + + public readonly document: string; + public readonly version: number; + public readonly position: vscode.Position; + public readonly context: RequestContext; + + constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext) { + this.document = document.uri.toString(); + this.version = document.version; + this.position = position; + this.context = context; + } +} + +class InflightRequestInfo { + + public readonly document: string; + public readonly position: vscode.Position; + public readonly requestId: string; + public readonly source: KnownSources | string; + public readonly serverPromise: Thenable; + + private readonly tokenSource: vscode.CancellationTokenSource; + + constructor(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, tokenSource: vscode.CancellationTokenSource, serverPromise: Thenable) { + this.document = document.uri.toString(); + this.position = position; + this.requestId = context.requestId; + this.source = context.source ?? KnownSources.unknown; + this.tokenSource = tokenSource; + this.serverPromise = serverPromise; + } + + public matches(document: vscode.TextDocument, position: vscode.Position): boolean { + return this.document === document.uri.toString() && this.position.isEqual(position); + } + + public matchesDocument(document: vscode.TextDocument): boolean { + return this.document === document.uri.toString(); + } + + public cancel(): void { + this.tokenSource.cancel(); + } +} + +class OnTimeoutData { + + private readonly document: string; + private readonly version: number; + private readonly position: vscode.Position; + + public readonly runnableResults: ResolvedRunnableResult[] = []; + public resultBuilder: ContextItemResultBuilder | undefined; + + constructor(document: vscode.TextDocument, position: vscode.Position) { + this.document = document.uri.toString(); + this.version = document.version; + this.position = position; + } + + addRunnableResult(result: ResolvedRunnableResult): void { + this.runnableResults.push(result); + } + + addRunnableResults(results: readonly ResolvedRunnableResult[]): void { + this.runnableResults.push(...results); + } + + matches(document: vscode.TextDocument, position: vscode.Position): boolean { + return this.document === document.uri.toString() && this.version === document.version && this.position.isEqual(position); + } +} + +export class TS6LanguageContextService extends AbstractTSLanguageContextService { + + private static readonly defaultCachePopulationRaceTimeout: number = 20; + private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic }; + + readonly _serviceBrand: undefined; + + private readonly isDebugging: boolean; + private _isActivated: Promise | undefined; + + private pendingRequest: PendingRequestInfo | undefined; + private inflightCachePopulationRequest: InflightRequestInfo | undefined; + private onTimeoutData: OnTimeoutData | undefined; + + constructor( + telemetryService: ITelemetryService, + configurationService: IConfigurationService, + experimentationService: IExperimentationService, + logService: ILogService + ) { + super(telemetryService, logService, configurationService, experimentationService); + this.isDebugging = process.execArgv.some((arg) => /^--(?:inspect|debug)(?:-brk)?(?:=\d+)?$/i.test(arg)); + this.pendingRequest = undefined; + this.inflightCachePopulationRequest = undefined; + this.onTimeoutData = undefined; + } + + public override dispose(): void { + this.inflightCachePopulationRequest?.cancel(); + this.inflightCachePopulationRequest = undefined; + super.dispose(); + } + + async isActivated(documentOrLanguageId: vscode.TextDocument | string): Promise { + const languageId = typeof documentOrLanguageId === 'string' ? documentOrLanguageId : documentOrLanguageId.languageId; + if (languageId !== 'typescript' && languageId !== 'typescriptreact') { + return false; + } + if (this._isActivated === undefined) { + this._isActivated = this.doIsTypeScriptActivated(languageId); + } + return this._isActivated; + } + + private async doIsTypeScriptActivated(languageId: string): Promise { + + let activated = false; + + try { + // Check that the TypeScript extension is installed and runs in the same extension host. + const typeScriptExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); + if (typeScriptExtension === undefined) { + return false; + } + + // Make sure the TypeScript extension is activated. + await typeScriptExtension.activate(); + + // Send a ping request to see if the TS server plugin got installed correctly. + const response: protocol.PingResponse | undefined = await vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.ping', TS6LanguageContextService.ExecConfig, CancellationToken.None); + this.telemetrySender.sendActivationTelemetry(response, undefined); + if (response !== undefined) { + if (response.body?.kind === 'ok') { + this.logService.info('TypeScript server plugin activated.'); + activated = true; + } else { + this.logService.error('TypeScript server plugin not activated:', response.body?.message ?? 'Message not provided.'); + } + } else { + this.logService.error('TypeScript server plugin not activated:', 'No ping response received.'); + } + } catch (error) { + this.telemetrySender.sendActivationTelemetry(undefined, error); + this.logService.error('Error pinging TypeScript server plugin:', error); + } + + return activated; + } + + async populateCache(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): Promise { + if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { + return; + } + if (this.inflightCachePopulationRequest !== undefined) { + if (!this.inflightCachePopulationRequest.matches(document, position)) { + // We have a request running. Do not issue another cache request but remember the pending request. + this.pendingRequest = new PendingRequestInfo(document, position, context); + } + return; + } + const startTime = Date.now(); + const contextRequestState = this.runnableResultManager.getContextRequestState(document, position); + if (contextRequestState !== undefined && contextRequestState.server.length === 0) { + // There is nothing to do on the server. Cache is up to date. + return; + } + const neighborFiles: string[] = this.neighborFileModel.getNeighborFiles(document); + const timeBudget = this.cachePopulationTimeout; + const willLogRequestTelemetry = this.telemetrySender.willLogRequestTelemetry(context); + const args: ComputeContextRequestArgs = ComputeContextRequestArgs.create( + document, position, context, startTime, timeBudget, willLogRequestTelemetry, + neighborFiles, contextRequestState?.server, this.includeDocumentation + ); + try { + const isDebugging = this.isDebugging; + const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; + const tokenSource = new vscode.CancellationTokenSource(); + const token = tokenSource.token; + const documentVersion = document.version; + const cacheState = this.runnableResultManager.getCacheState(); + let response: protocol.ComputeContextResponse; + let inflightRequest: InflightRequestInfo | undefined = undefined; + try { + const promise: Thenable = vscode.commands.executeCommand('typescript.tsserverRequest', '_.copilot.context', args, TS6LanguageContextService.ExecConfig, token); + inflightRequest = new InflightRequestInfo(document, position, context, tokenSource, promise); + this.inflightCachePopulationRequest = inflightRequest; + response = await promise; + } finally { + if (this.inflightCachePopulationRequest === inflightRequest) { + this.inflightCachePopulationRequest = undefined; + } + tokenSource.dispose(); + } + const timeTaken = Date.now() - startTime; + if (protocol.ComputeContextResponse.isCancelled(response)) { + this.telemetrySender.sendRequestCancelledTelemetry(context, timeTaken); + } else if (protocol.ComputeContextResponse.isOk(response)) { + const body: protocol.ComputeContextResponse.OK = response.body; + const contextItemResult = new ContextItemResultBuilder(timeTaken); + const { resolved, cached, referenced, serverComputed } = this.runnableResultManager.update(document, documentVersion, position, context, body, contextRequestState); + contextItemResult.cachedItems += cached; + contextItemResult.referencedItems += referenced; + contextItemResult.serverComputed = serverComputed; + if (resolved.length > 0) { + // Update the stats for telemetry. + for (const runnableResult of resolved) { + for (const converted of contextItemResult.update(runnableResult)) { + forDebugging?.push(converted.item); + } + } + } + contextItemResult.updateResponse(body, token); + this.telemetrySender.sendRequestTelemetry(document, position, context, contextItemResult, timeTaken, { before: cacheState, after: this.runnableResultManager.getCacheState() }, undefined); + // eslint-disable-next-line local/code-no-unused-expressions + isDebugging && forDebugging?.length; + this._onCachePopulated.fire({ document, position, source: context.source, items: resolved, summary: contextItemResult }); + } else if (protocol.ComputeContextResponse.isError(response)) { + this.telemetrySender.sendRequestFailureTelemetry(context, response.body); + this.logService.error('Error populating cache:', response.body.message); + } + } catch (error) { + this.logService.error(error, `Error populating cache for document: ${document.uri.toString()} at position: ${position.line + 1}:${position.character + 1}`); + } + if (this.pendingRequest !== undefined) { + // We had a pending request. Clear it and try to populate the cache again. + const pendingRequest = this.pendingRequest; + this.pendingRequest = undefined; + const textEditor = vscode.window.activeTextEditor; + if (textEditor !== undefined) { + const document = textEditor.document; + if (document.uri.toString() === pendingRequest.document && document.version === pendingRequest.version && document.validatePosition(pendingRequest.position).isEqual(pendingRequest.position)) { + this.populateCache(document, pendingRequest.position, pendingRequest.context).catch(() => { /* handled in populateCache */ }); + } + } + } + } + + public async *getContext(document: vscode.TextDocument, position: vscode.Position, context: RequestContext, token: vscode.CancellationToken): AsyncIterable { + this.onTimeoutData = undefined; + if (document.languageId !== 'typescript' && document.languageId !== 'typescriptreact') { + return; + } + + const startTime = Date.now(); + let cacheRequest = 'none'; + const cachePopulationRequestInflight = this.inflightCachePopulationRequest !== undefined && this.inflightCachePopulationRequest.matchesDocument(document); + if (cachePopulationRequestInflight) { + this.onTimeoutData = new OnTimeoutData(document, position); + cacheRequest = 'inflight'; + } + if (token.isCancellationRequested) { + this.telemetrySender.sendRequestCancelledTelemetry(context, Date.now() - startTime); + return; + } + const isDebugging = this.isDebugging; + const forDebugging: ContextItem[] | undefined = isDebugging ? [] : undefined; + const contextItemResult = new ContextItemResultBuilder(Date.now() - startTime); + if (this.onTimeoutData !== undefined) { + this.onTimeoutData.resultBuilder = contextItemResult; + } + const characterBudget = this.getCharacterBudget(context, document); + // We first collect all items to yield so that the state of the cache doesn't change underneath us. + // This could otherwise happen if the cache population request finishes while we are yielding items. + const itemsToYield: ContextItem[] = []; + const { mandatory, optional, onTimeout } = this.getRunnables(document, position, cachePopulationRequestInflight); + if (this.onTimeoutData !== undefined) { + this.onTimeoutData.addRunnableResults(onTimeout); + } + outer: for (const runnableResult of mandatory) { + for (const { item, size } of contextItemResult.update(runnableResult, true)) { + forDebugging?.push(item); + characterBudget.spend(size); + if (characterBudget.isExhausted()) { + break outer; + } + itemsToYield.push(item); + } + } + if (!characterBudget.isOptionalExhausted()) { + outer: for (const runnableResult of optional) { + for (const { item, size } of contextItemResult.update(runnableResult, true)) { + forDebugging?.push(item); + characterBudget.spend(size); + if (characterBudget.isOptionalExhausted()) { + break outer; + } + itemsToYield.push(item); + } + } + } + if (!token.isCancellationRequested) { + for (const item of itemsToYield) { + if (token.isCancellationRequested) { + this.onTimeoutData = undefined; + break; + } + yield item; + } + + // Recheck for an inflight request and join it if it is for the same document and position. + if (this.inflightCachePopulationRequest !== undefined && this.inflightCachePopulationRequest.matchesDocument(document)) { + cacheRequest = 'inflight'; + // We have an inflight request for the same document and position. + // We wait for the server promise to resolve and then see if we can yield items from the + // inflight request. + const timeOut = Math.max(0, Math.min(context.timeBudget ?? TS6LanguageContextService.defaultCachePopulationRaceTimeout, TS6LanguageContextService.defaultCachePopulationRaceTimeout)); + const result = await Promise.race([this.inflightCachePopulationRequest.serverPromise, new Promise((resolve) => setTimeout(resolve, timeOut)).then(() => 'timedOut')]); + // The server promised resolved first. So the inflight request is done. + if (result !== 'timedOut') { + this.inflightCachePopulationRequest = undefined; + if (this.onTimeoutData !== undefined) { + this.onTimeoutData = undefined; + const runnableResults = this.runnableResultManager.getCachedRunnableResults(document, position, protocol.EmitMode.ClientBasedOnTimeout); + for (const runnableResult of runnableResults) { + for (const { item } of contextItemResult.update(runnableResult)) { + forDebugging?.push(item); + yield item; + } + } + cacheRequest = 'awaited'; + } + } + } + } else { + this.onTimeoutData = undefined; + } + + const isSpeculativeRequest = context.proposedEdits !== undefined; + if (isSpeculativeRequest) { + this.telemetrySender.sendSpeculativeRequestTelemetry(context, this.runnableResultManager.getRequestId() ?? 'unknown', contextItemResult.stats.yielded); + } else { + const cacheState = this.runnableResultManager.getCacheState(); + contextItemResult.path = this.runnableResultManager.getNodePath(); + contextItemResult.cancelled = token.isCancellationRequested; + contextItemResult.serverTime = 0; + contextItemResult.contextComputeTime = 0; + contextItemResult.fromCache = true; + this.telemetrySender.sendRequestTelemetry( + document, position, context, contextItemResult, Date.now() - startTime, + { before: cacheState, after: cacheState }, cacheRequest + ); + // eslint-disable-next-line local/code-no-unused-expressions + isDebugging && forDebugging?.length; + this._onContextComputed.fire({ + document, position, source: context.source, items: itemsToYield, summary: contextItemResult + }); + } + return; + } + + private getRunnables(document: vscode.TextDocument, position: vscode.Position, cachePopulationInflight: boolean): { mandatory: readonly ResolvedRunnableResult[]; optional: readonly ResolvedRunnableResult[]; onTimeout: readonly ResolvedRunnableResult[] } { + const mandatory: ResolvedRunnableResult[] = []; + const optional: ResolvedRunnableResult[] = []; + const onTimeout: ResolvedRunnableResult[] = []; + for (const runnable of this.runnableResultManager.getCachedRunnableResults(document, position)) { + if (cachePopulationInflight && runnable.cache?.emitMode === protocol.EmitMode.ClientBasedOnTimeout) { + onTimeout.push(runnable); + } else { + const priority = runnable.priority; + if (priority === protocol.Priorities.Expression || priority === protocol.Priorities.Locals || priority === protocol.Priorities.Inherited || priority === protocol.Priorities.Traits) { + mandatory.push(runnable); + } else { + optional.push(runnable); + } + } + } + return { mandatory, optional, onTimeout }; + } + + public getContextOnTimeout(document: vscode.TextDocument, position: vscode.Position, context: RequestContext): readonly ContextItem[] | undefined { + try { + if (this.onTimeoutData === undefined) { + return []; + } + if (!this.onTimeoutData.matches(document, position) || this.onTimeoutData.resultBuilder === undefined) { + return []; + } + const result: ContextItem[] = []; + const contextItemResult = this.onTimeoutData.resultBuilder; + for (const runnableResult of this.onTimeoutData.runnableResults) { + for (const { item } of contextItemResult.update(runnableResult, true)) { + result.push(item); + } + } + return result; + } finally { + this.onTimeoutData = undefined; + } + } +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts index 0f4bcdc3904eb9..97cbb6f396e548 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts @@ -8,6 +8,28 @@ import * as vscode from 'vscode'; import { ContextKind, type ContextItem, type ILanguageContextService } from '../../../platform/languageServer/common/languageContextService'; import * as protocol from '../common/serverProtocol'; +export enum ErrorLocation { + Client = 'client', + Server = 'server' +} + +export enum ErrorPart { + ServerPlugin = 'server-plugin', + TypescriptPlugin = 'typescript-plugin', + CopilotExtension = 'copilot-extension' +} + +export type CacheInfo = { + version: number; + state: CacheState; +}; + +export enum CacheState { + NotPopulated = 'NotPopulated', + PartiallyPopulated = 'PartiallyPopulated', + FullyPopulated = 'FullyPopulated' +} + export type ResolvedRunnableResult = { id: protocol.ContextRunnableResultId; state: protocol.ContextRunnableState; @@ -16,6 +38,7 @@ export type ResolvedRunnableResult = { cache?: protocol.CacheInfo; debugPath?: protocol.ContextRunnableResultId | undefined; }; + export namespace ResolvedRunnableResult { export function from(result: protocol.ContextRunnableResult, items: protocol.FullContextItem[]): ResolvedRunnableResult { return { @@ -29,6 +52,25 @@ export namespace ResolvedRunnableResult { } } +export enum ContextItemUsageMode { + minimal = 'minimal', + double = 'double', + fillHalf = 'fillHalf', + fill = 'fill' +} + +export namespace ContextItemUsageMode { + export function fromString(value: string): ContextItemUsageMode { + switch (value) { + case 'minimal': return ContextItemUsageMode.minimal; + case 'double': return ContextItemUsageMode.double; + case 'fillHalf': return ContextItemUsageMode.fillHalf; + case 'fill': return ContextItemUsageMode.fill; + default: return ContextItemUsageMode.minimal; + } + } +} + export type ContextComputedEvent = { document: vscode.TextDocument; position: vscode.Position; @@ -105,6 +147,7 @@ export interface ContextItemSummary { contextComputeTime: number; totalTime: number; } + export namespace ContextItemSummary { export const DefaultExhausted: ContextItemSummary = Object.freeze({ path: [0], @@ -230,4 +273,4 @@ export class ContextItemResultBuilder implements ContextItemSummary { } return undefined; } -} \ No newline at end of file +} diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 4f384257ec527c..c5db8a27112f02 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -1038,6 +1038,7 @@ export namespace ConfigKey { export const TypeScriptLanguageContextCacheTimeout = defineSetting('chat.languageContext.typescript.cacheTimeout', ConfigType.ExperimentBased, 500); export const TypeScriptLanguageContextFix = defineSetting('chat.languageContext.fix.typescript.enabled', ConfigType.ExperimentBased, false); export const TypeScriptLanguageContextInline = defineSetting('chat.languageContext.inline.typescript.enabled', ConfigType.ExperimentBased, false); + export const TypeScript7LanguageContext = defineSetting('chat.languageContext.typescript7.enabled', ConfigType.Simple, false); export const UseInstructionFiles = defineSetting('chat.codeGeneration.useInstructionFiles', ConfigType.Simple, true); export const ReviewAgent = defineSetting('chat.reviewAgent.enabled', ConfigType.Simple, true); diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index e11babc4d4073f..b7373c4c2c2b7d 100644 --- a/src/vs/platform/agentHost/common/sessionConfigKeys.ts +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -33,6 +33,8 @@ export const enum SessionConfigKey { WorktreeIncludeFiles = 'worktreeIncludeFiles', /** `'worktreeBranchTrack'` — host-owned branch tracking preference for programmatic session creation. */ WorktreeBranchTrack = 'worktreeBranchTrack', + /** `'worktreeCreateNewBranch'` — host-owned choice to create a branch instead of checking out the selected branch. */ + WorktreeCreateNewBranch = 'worktreeCreateNewBranch', /** `'agentMerge'` — client-owned Agent Merge enablement and session overrides. */ AgentMerge = 'agentMerge', /** `'agentMerge.controller'` — host-owned Agent Merge lifecycle state. */ diff --git a/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts b/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts index 2b1dbef11d6ae0..136280856d9141 100644 --- a/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts +++ b/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts @@ -46,7 +46,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent this._logService.trace(`[AgentHostAuthenticationService] authenticate called: resource=${params.resource}`); const providerList = [...providers]; // Multiple providers may share the same protected resource (e.g. - // both Copilot CLI and Claude consume the GitHub Copilot token). + // both Copilot CLI and Claude consume the Copilot-scoped OAuth credential). // Fan out to every matching provider in parallel; the request is // considered authenticated if at least one accepts. Provider // failures are isolated -- one provider rejecting (e.g. proxy diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index e29b9d17b1478b..b875b508f47499 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -215,7 +215,8 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } private _hasWorkingDirectory(session: ProtocolURI): boolean { - return !!this._configurationService.getEffectiveWorkingDirectories(session)?.[0]; + return !this._configurationService.isWorkingDirectoryPending(session) + && !!this._configurationService.getEffectiveWorkingDirectories(session)?.[0]; } registerStaticChangesets(session: ProtocolURI): void { diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts index dee479e7a69b5e..7132ea2545dafb 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts @@ -205,7 +205,7 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl } const message = err instanceof Error ? err.message : String(err); return /\b(401|403)\b/.test(message) - && /\b(auth|authorization|unauthorized|forbidden|token|copilot endpoint discovery|copilot session token mint)\b/i.test(message); + && /\b(auth|authorization|unauthorized|forbidden|token|copilot endpoint discovery)\b/i.test(message); } private _throwIfCancelled(token: CancellationToken): void { diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index fff3b2b5edacf2..94f7b37405326f 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -338,8 +338,10 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi async resolveSessionBaseBranchName(sessionKey: string): Promise { const state = this._stateManager.getSessionState(sessionKey); - const configuredBranch = state?.config?.values[SessionConfigKey.Isolation] === 'worktree' - ? state.config.values[SessionConfigKey.Branch] + const configValues = state?.config?.values; + const configuredBranch = configValues?.[SessionConfigKey.Isolation] === 'worktree' + && configValues[SessionConfigKey.WorktreeCreateNewBranch] !== false + ? configValues[SessionConfigKey.Branch] : undefined; if (typeof configuredBranch === 'string' && configuredBranch.trim()) { return resolveDiffBaseBranchName(configuredBranch.trim(), undefined); diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 85eca08409dc74..8bef4a7dd6b6fa 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -347,7 +347,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation * markdown text of user requests and agent responses — tool calls, * subagents, and reasoning are excluded and the text is character-bounded) * along with a summary of the changed files. Returns `undefined` when no - * Copilot token is available or generation fails, so the caller can fall + * Copilot OAuth credential is available or generation fails, so the caller can fall * back to the branch-name based title/description. PR creation must never * fail just because the model is unavailable. */ @@ -360,11 +360,11 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation token: CancellationToken, ): Promise<{ title: string; description: string } | undefined> { const copilotResource = this._gitHubEndpointService.getCopilotResource(); - const copilotToken = this._agentService.getAuthToken({ + const authToken = this._agentService.getAuthToken({ resource: copilotResource.resource, scopes: copilotResource.scopes_supported, }); - if (!copilotToken) { + if (!authToken) { return undefined; } @@ -375,7 +375,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation } try { - const raw = await this._copilotApiService.utilityChatCompletion(copilotToken, { + const raw = await this._copilotApiService.utilityChatCompletion(authToken, { messages: this._buildTitleAndDescriptionPrompt(branchName, base, conversation, changeSummary), }, { signal }); this._throwIfCancelled(token); diff --git a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts index 3d96276dff334a..e81ccd53e2f8e1 100644 --- a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts @@ -137,7 +137,7 @@ export interface IAgentHostRestrictedTelemetry { setCommonProperty(name: string, value: string | boolean): void; /** Overrides the POST endpoint with the user's CAPI `endpoints.telemetry`; falsy restores the default. */ setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void; - /** Enables enhanced GH telemetry once the token opts in (`rt=1`); off by default and on flip/logout. */ + /** Enables enhanced GH telemetry once the authenticated account opts in; off by default and on flip/logout. */ setRestrictedTelemetryEnabled(enabled: boolean): void; /** Sets the internal-user identity and enables the internal sink only for staff accounts. */ setInternalTelemetryContext(context: IAgentHostInternalTelemetryContext | undefined): void; @@ -153,10 +153,8 @@ export class AgentHostRestrictedTelemetrySender implements IAgentHostRestrictedT private readonly _commonProps: TelemetryProps; /** - * Whether the current Copilot token opts into enhanced/restricted telemetry (`rt=1`). Off by - * default so the sole writer to the restricted table never emits for public users — a hard - * safety boundary that holds even if the enclosing service's gate is bypassed. Mirrors the - * Copilot extension, which only creates the restricted reporter for opted-in users. + * Whether `/copilot_internal/user` enables enhanced/restricted telemetry. Off by default so + * the sole writer to the restricted table never emits for public users. */ private _restrictedTelemetryEnabled = false; private _internalTelemetryEnabled = false; @@ -185,8 +183,8 @@ export class AgentHostRestrictedTelemetrySender implements IAgentHostRestrictedT sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { // Hard safety boundary: enhanced/restricted telemetry is the pipeline that may carry prompt // and tool content, so the only writer to the restricted table refuses to emit unless the - // user's token opted in (`rt=1`). This holds even if a caller reaches the sender without the - // service-level `rt`/telemetry-level gate. + // authenticated account opted in. This holds even if a caller reaches the sender without the + // service-level restricted-telemetry gate. if (!this._restrictedTelemetryEnabled) { return; } @@ -226,9 +224,8 @@ export class AgentHostRestrictedTelemetrySender implements IAgentHostRestrictedT } setCopilotTrackingId(trackingId: string | undefined): void { - // `copilot_trackingId` is the current account's Copilot token `tid` claim. Exact runtime - // targets use their immutable per-session context instead; this mutable value remains for - // the pre-existing account-scoped reporters. + // Exact runtime targets use their immutable per-session context; this mutable value remains + // for the pre-existing account-scoped reporters. this._commonProps.copilot_trackingId = trackingId || undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts index 0614f8686f9776..3a521f9d0bb19c 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts @@ -50,9 +50,8 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT private _telemetryLevel: TelemetryLevel; /** - * Whether the current Copilot token opts into enhanced/restricted telemetry (`rt=1`). Defaults - * to `false` so nothing restricted is sent until an authenticated token confirms the opt-in, - * keeping public users off the enhanced pipeline the way the Copilot extension does. + * Whether `/copilot_internal/user` enables enhanced/restricted telemetry. Defaults to `false` + * so nothing restricted is sent until the authenticated account confirms the opt-in. */ private _restrictedTelemetryEnabled = false; private _internalTelemetryEnabled = false; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 241ae294e7ba0b..4b5c72a560cc31 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -140,6 +140,7 @@ const HOST_OWNED_SESSION_CONFIG_KEYS = [ SessionConfigKey.WorktreeBranchPrefix, SessionConfigKey.WorktreeIncludeFiles, SessionConfigKey.WorktreeBranchTrack, + SessionConfigKey.WorktreeCreateNewBranch, ] as const; /** @@ -2689,8 +2690,10 @@ export class AgentService extends Disposable implements IAgentService { } } - const workingDirectory = created.resolvedWorkingDirectory ?? config?.workingDirectories?.[0]; - void this._gitStateService.refreshSessionGitState(session.toString(), workingDirectory); + if (!this._configurationService.isWorkingDirectoryPending(session.toString())) { + const workingDirectory = created.resolvedWorkingDirectory ?? config?.workingDirectories?.[0]; + void this._gitStateService.refreshSessionGitState(session.toString(), workingDirectory); + } return session; } @@ -3643,6 +3646,9 @@ export class AgentService extends Disposable implements IAgentService { if (iso.worktreeBranchTrackProperty) { properties[SessionConfigKey.WorktreeBranchTrack] = iso.worktreeBranchTrackProperty.protocol; } + if (iso.worktreeCreateNewBranchProperty) { + properties[SessionConfigKey.WorktreeCreateNewBranch] = iso.worktreeCreateNewBranchProperty.protocol; + } if (iso.worktreeIncludeFilesProperty) { properties[SessionConfigKey.WorktreeIncludeFiles] = iso.worktreeIncludeFilesProperty.protocol; } @@ -3657,6 +3663,9 @@ export class AgentService extends Disposable implements IAgentService { if (iso.worktreeBranchTrackProperty && typeof params.config?.[SessionConfigKey.WorktreeBranchTrack] === 'boolean') { values[SessionConfigKey.WorktreeBranchTrack] = params.config[SessionConfigKey.WorktreeBranchTrack]; } + if (iso.worktreeCreateNewBranchProperty && typeof params.config?.[SessionConfigKey.WorktreeCreateNewBranch] === 'boolean') { + values[SessionConfigKey.WorktreeCreateNewBranch] = params.config[SessionConfigKey.WorktreeCreateNewBranch]; + } if (iso.worktreeIncludeFilesProperty && Array.isArray(params.config?.[SessionConfigKey.WorktreeIncludeFiles]) && params.config[SessionConfigKey.WorktreeIncludeFiles].every(pattern => typeof pattern === 'string')) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 4d3e85d315f281..7e539c16c40cd2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -758,7 +758,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._serverToolHost = host; } - /** Reflects the `rt=1` field on the GitHub Copilot bearer token; gates enhanced GH telemetry. */ + /** Reflects the restricted-telemetry entitlement from `/copilot_internal/user`. */ private _restrictedTelemetryEnabled = false; private readonly _onDidChangeRestrictedTelemetry = this._register(new Emitter()); readonly onDidChangeRestrictedTelemetry = this._onDidChangeRestrictedTelemetry.event; @@ -1550,11 +1550,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private _updateRestrictedTelemetry(githubToken: string | undefined): void { - // Safe default synchronously: keep restricted/enhanced telemetry disabled until the minted - // CAPI Copilot session token confirms the `rt=1` opt-in. The GitHub token here carries no - // `rt`/`tid` claims — those live in the Copilot session token, which the API service mints — - // so the real values are resolved asynchronously below. Mirrors how the Copilot extension - // reads `rt`/`tid` off its `CopilotToken` rather than the GitHub token. + // Keep restricted telemetry disabled until `/copilot_internal/user` confirms the opt-in. this._applyRestrictedTelemetry(undefined); if (githubToken) { void this._resolveRestrictedTelemetry(githubToken); diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts index ffca3b1c5276f1..cbeec003cee0e2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts @@ -197,7 +197,7 @@ import { ITelemetryData, ITelemetryService } from '../../../telemetry/common/tel * Microsoft cluster/database as the rest of the agent host's telemetry. * * Restricted events (`cli.restricted_telemetry`) are only forwarded when - * restricted telemetry is enabled for the current Copilot token; standard + * restricted telemetry is enabled for the current Copilot account; standard * events always flow through. */ export class CopilotGitHubTelemetryForwarder { diff --git a/src/vs/platform/agentHost/node/copilot/copilotTokenFields.ts b/src/vs/platform/agentHost/node/copilot/copilotTokenFields.ts deleted file mode 100644 index 998236f0434240..00000000000000 --- a/src/vs/platform/agentHost/node/copilot/copilotTokenFields.ts +++ /dev/null @@ -1,26 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** Parses the `key=value;...` field map from the leading colon-delimited segment of a Copilot token (e.g. `tid=abc;exp=123;rt=1:HMAC...`). */ -export function parseCopilotTokenFields(token: string | undefined): ReadonlyMap { - const result = new Map(); - if (!token) { - return result; - } - const colonIdx = token.indexOf(':'); - const header = colonIdx === -1 ? token : token.substring(0, colonIdx); - for (const field of header.split(';')) { - const eqIdx = field.indexOf('='); - if (eqIdx <= 0) { - continue; - } - result.set(field.substring(0, eqIdx), field.substring(eqIdx + 1)); - } - return result; -} - -export function isRestrictedTelemetryEnabled(token: string | undefined): boolean { - return parseCopilotTokenFields(token).get('rt') === '1'; -} diff --git a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts index ce14e16bf967ff..70ec357ff4c9ac 100644 --- a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts @@ -45,7 +45,7 @@ export const artifactServerToolDefinitions: ToolDefinition[] = [ { name: ArtifactServerToolName.AddArtifact, title: 'Add Artifact', - description: 'Record something the user will want to open — a pull request, issue, notable commit, website, file or other resource — so it is surfaced next to the chat input.', + description: 'Record something the user will want to open — a pull request, issue, commit found while investigating or answering a question, website, file or other resource — so it is surfaced next to the chat input. Do not record commits you create unless the user explicitly asks you to add them as artifacts.', inputSchema: addArtifactInputSchema, annotations: { readOnlyHint: false }, }, @@ -172,4 +172,4 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce * The instruction appended to every agent's host instructions while the * artifact tools are enabled. */ -export const ARTIFACT_TOOLS_INSTRUCTION = `When you produce something the user will want to open — a pull request, an issue, a notable commit, a website, a plan file or another resource — record it once with \`${ArtifactServerToolName.AddArtifact}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits). Do not record routine files you merely edited, and do not record every commit you make — record a commit only when the user asked you to commit, or when you found a commit worth showing them, for example while investigating.`; +export const ARTIFACT_TOOLS_INSTRUCTION = `When you produce something the user will want to open — a pull request, an issue, a website, a plan file or another resource — or find a notable commit worth showing the user while investigating or answering a question, record it once with \`${ArtifactServerToolName.AddArtifact}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits). Do not record routine files you merely edited. Do not record commits you create unless the user explicitly asks you to add them as artifacts.`; diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts index 5113d82d3ffa65..7735f9c7bdd991 100644 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ b/src/vs/platform/agentHost/node/shared/copilotApiService.ts @@ -7,12 +7,12 @@ import type Anthropic from '@anthropic-ai/sdk'; import { CAPIClient, RequestType, type CCAModel, type IExtensionInformation } from '@vscode/copilot-api'; import { generateUuid } from '../../../../base/common/uuid.js'; import { getDevDeviceId, getMachineId } from '../../../../base/node/id.js'; +import { getInternalOrg } from '../../../assignment/common/assignment.js'; +import { COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgreement.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; -import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; -import { COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgreement.js'; -import { parseCopilotTokenFields } from '../copilot/copilotTokenFields.js'; +import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; // #region Types @@ -23,9 +23,8 @@ import { parseCopilotTokenFields } from '../copilot/copilotTokenFields.js'; * sensitive headers (`Authorization`, `Content-Type`, `X-Request-Id`, * `OpenAI-Intent`), so callers cannot override those. * - * `signal` propagates to the outgoing API request but **not** to the - * shared token mint. The mint is deduped across concurrent callers, so - * a single caller's abort must not cancel it for everyone. + * `signal` propagates to the outgoing API request but not to the shared + * endpoint-discovery request. */ export interface ICopilotApiServiceRequestOptions { readonly headers?: Readonly>; @@ -76,13 +75,16 @@ export interface ICopilotUtilityChatCompletionRequest { /** * Subset of the GitHub `copilot_internal/user` response we care about. - * The full payload carries entitlement info; we only need `endpoints` (for - * routing CAPI requests) and `access_type_sku` (which `CAPIClient.updateDomains` - * stamps onto requests). + * Provides CAPI routing and SKU data together with the account metadata used + * for restricted and internal telemetry. */ interface ICopilotUserResponse { readonly login?: string; readonly copilotignore_enabled?: boolean; + readonly restricted_telemetry?: boolean; + readonly analytics_tracking_id?: string; + readonly is_staff?: boolean; + readonly organization_login_list?: readonly string[]; readonly endpoints?: { readonly api?: string; readonly telemetry?: string; @@ -105,22 +107,8 @@ interface ICachedClient { /** The CAPI `endpoints.api` base URL discovered (or overridden) for this token, if any. */ readonly apiEndpoint?: string; readonly copilotIgnoreEnabled?: boolean; -} - -/** - * Subset of the `RequestType.CopilotToken` mint response we care about. - */ -interface ICopilotTokenEnvelope { - readonly token?: unknown; - readonly expires_at?: unknown; - readonly refresh_in?: unknown; - readonly organization_list?: unknown; -} - -/** Per-GitHub-token Copilot session token cache entry. */ -interface ICachedCopilotToken { - readonly token: string; - readonly expiresAt: number; + readonly restrictedTelemetryEnabled: boolean; + readonly trackingId?: string; readonly isInternal: boolean; readonly isVscodeTeamMember: boolean; } @@ -177,7 +165,6 @@ const USER_API_VERSION = '2025-04-01'; const CAPI_URL_OVERRIDE_ENV = 'VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE'; const CAPI_URL_OVERRIDE_SMOKE_TEST_HOST = 'vscode-smoke.test'; const CAPI_URL_OVERRIDE_SMOKE_TEST_ENV = 'VSCODE_SMOKE_TEST_PROXY_HEADER'; -const GITHUB_API_URL_OVERRIDE_ENV = 'COPILOT_DEBUG_GITHUB_API_URL'; /** True iff `url` parses and its host is a loopback address (localhost / 127.0.0.0/8 / ::1). */ function isLoopbackUrl(url: string): boolean { @@ -206,13 +193,6 @@ function isAllowedCapiUrlOverride(url: string): boolean { } } -/** - * Re-mint the Copilot session token this many seconds before its - * server-reported `expires_at`, mirroring the Copilot Chat extension's - * `RefreshableCopilotTokenManager` 5-minute refresh buffer. - */ -const COPILOT_TOKEN_REFRESH_BUFFER_SECONDS = 5 * 60; - /** * Default CAPI model family for {@link ICopilotApiService.utilityChatCompletion}. * Matches the Copilot Chat extension's `copilot-utility-small` resolver @@ -239,15 +219,6 @@ const UTILITY_DEFAULT_TOP_P = 1; */ const UTILITY_INTENT = 'conversation-background'; -const INTERNAL_COPILOT_ORGANIZATIONS = new Set([ - '4535c7beffc844b46bb1ed4aa04d759a', - 'a5db0bcaae94032fe715fb34a5e4bce2', - '7184f66dfcee98cb5f08a1cb936d5225', - '1cb18ac6eedd49b43d74a1c5beb0b955', - 'ea9395b9a9248c05ee6847cbd24355ed', -]); -const VSCODE_COPILOT_ORGANIZATIONS = new Set(['551cca60ce19654d894e786220822482']); - // #endregion // #region Errors @@ -371,13 +342,6 @@ export const ICopilotApiService = createDecorator('copilotAp * works for both consumer (`api.githubcopilot.com`) and Enterprise * (`api.enterprise.githubcopilot.com`) accounts without configuration. * - * {@link utilityChatCompletion} is the one exception to the - * GitHub-token-IS-the-credential rule: CAPI's `/chat/completions` endpoint - * expects a Copilot session token (the same one the Copilot Chat extension - * mints via `RequestType.CopilotToken`). The service mints it internally - * from the supplied GitHub token, caches it per-token alongside the - * resolved utility model id, and refreshes ahead of expiry. - * * ## Non-goals * * - Per-conversation history, retry/backoff, or rate-limit handling. Callers @@ -417,14 +381,12 @@ export const ICopilotApiService = createDecorator('copilotAp * - Malformed JSON in an SSE `data:` line is logged and skipped, not thrown. */ /** - * Restricted/enhanced telemetry context derived from a user's minted CAPI Copilot session token, - * mirroring what the Copilot extension reads off its `CopilotToken` (`rt` opt-in, `tid` tracking id) - * plus the CAPI `endpoints.telemetry` host. + * Restricted/enhanced telemetry context derived from the GitHub `/copilot_internal/user` response. */ export interface IRestrictedTelemetryContext { - /** Whether the token opts into enhanced/restricted telemetry (the `rt=1` claim). */ + /** Whether `/copilot_internal/user` enables enhanced/restricted telemetry. */ readonly restrictedTelemetryEnabled: boolean; - /** The Copilot user tracking id (`tid` claim), or `undefined` when absent. */ + /** The Copilot analytics tracking ID, or `undefined` when absent. */ readonly trackingId: string | undefined; /** The CAPI `endpoints.telemetry` base URL, resolved only when enabled; `undefined` otherwise. */ readonly telemetryEndpoint: string | undefined; @@ -512,12 +474,10 @@ export interface ICopilotApiService { * Send arbitrary user chat messages through CAPI's `/chat/completions` * endpoint and return the assistant text. * - * Internally mints (and caches) a Copilot session token from the - * supplied GitHub token — the same flow the Copilot Chat extension - * uses for its `copilot-utility-small` endpoint (PR title/description, - * commit messages, branch names, chat titles, etc.). Uses the - * `gpt-4o-mini` model family with `top_p = 1` and `temperature = 0.1` - * by default (override via `request.temperature`). + * Uses the supplied GitHub OAuth token directly. This is the same + * credential flow as the other CAPI model endpoints. Uses the `gpt-4o-mini` + * model family with `top_p = 1` and `temperature = 0.1` by default + * (override via `request.temperature`). * * Non-streaming. Callers own prompt construction and any * domain-specific parsing of the returned text. @@ -533,11 +493,8 @@ export interface ICopilotApiService { ): Promise; /** - * Resolve this user's restricted-telemetry context from the minted CAPI Copilot session token — - * the `rt` opt-in and `tid` tracking id — plus the CAPI `endpoints.telemetry` host. The GitHub - * token itself carries none of these claims; they live in the Copilot session token (minted via - * `RequestType.CopilotToken`), exactly as the Copilot extension reads them off its `CopilotToken`. - * The telemetry endpoint is resolved only when enabled, so public users incur no extra discovery. + * Resolve this user's restricted-telemetry context from `/copilot_internal/user`. + * The telemetry endpoint is returned only when restricted telemetry is enabled. */ resolveRestrictedTelemetryContext(githubToken: string): Promise; @@ -564,7 +521,6 @@ export class CopilotApiService implements ICopilotApiService { private _capiBasePromise: Promise | null = null; private readonly _clientsByToken = new Map>(); - private readonly _copilotTokensByGithub = new Map>(); private readonly _fetch: FetchFunction; constructor( @@ -889,28 +845,18 @@ export class CopilotApiService implements ICopilotApiService { return this._getEntryForToken(githubToken).then(entry => entry.capiClient); } - /** - * Resolve this user's restricted-telemetry context. Reads the `rt`/`tid` claims from the minted - * CAPI Copilot session token (the GitHub token has neither), and resolves the CAPI - * `endpoints.telemetry` host from the cached `/copilot_internal/user` discovery only when the - * user is opted in, so public users pay no extra discovery call. - */ async resolveRestrictedTelemetryContext(githubToken: string): Promise { - const token = await this._getCopilotTokenEntry(githubToken); const client = await this._getEntryForToken(githubToken); - const fields = parseCopilotTokenFields(token.token); - const restrictedTelemetryEnabled = fields.get('rt') === '1'; - const trackingId = fields.get('tid'); - const telemetryEndpoint = restrictedTelemetryEnabled + const telemetryEndpoint = client.restrictedTelemetryEnabled ? client.telemetryEndpoint : undefined; return { - restrictedTelemetryEnabled, - trackingId, + restrictedTelemetryEnabled: client.restrictedTelemetryEnabled, + trackingId: client.trackingId, telemetryEndpoint, - isInternal: token.isInternal, + isInternal: client.isInternal, userName: client.login, - isVscodeTeamMember: token.isVscodeTeamMember, + isVscodeTeamMember: client.isVscodeTeamMember, copilotIgnoreEnabled: client.copilotIgnoreEnabled, }; } @@ -987,6 +933,9 @@ export class CopilotApiService implements ICopilotApiService { expiresAt: Date.now() / 1000 + CAPI_CONTEXT_TTL_SECONDS, utilityModelIdsByFamily: new Map(), apiEndpoint: overrideApi, + restrictedTelemetryEnabled: false, + isInternal: false, + isVscodeTeamMember: false, }; } this._logService.warn(`[CopilotApiService] Ignoring non-loopback CAPI URL override ${overrideApi}; falling back to normal endpoint discovery`); @@ -1007,14 +956,12 @@ export class CopilotApiService implements ICopilotApiService { } const envelope: ICopilotUserResponse = await response.json(); + const internalOrganization = getInternalOrg(envelope.organization_login_list); capiClient.updateDomains( { endpoints: envelope.endpoints ?? {}, sku: envelope.access_type_sku ?? '' }, // Enterprise base URI (e.g. `https://acme.ghe.com`), or `undefined` for - // github.com. The package derives the GitHub API host (`api.`) from - // this for `copilot_internal` endpoints - notably the Copilot session - // token mint (`/copilot_internal/v2/token`). Omitting it strands the mint - // on `api.github.com`, which 401s an enterprise token ("Bad credentials"). + // github.com. The package uses this when routing enterprise CAPI requests. this._gitHubEndpointService.getEnterpriseUri(), ); @@ -1029,108 +976,10 @@ export class CopilotApiService implements ICopilotApiService { telemetryEndpoint: envelope.endpoints?.telemetry, apiEndpoint: envelope.endpoints?.api, copilotIgnoreEnabled: envelope.copilotignore_enabled, - }; - } - - // #endregion - - // #region Per-Token Copilot Session Token - - /** - * Resolve the Copilot session token for a GitHub token, minting and - * caching one if needed. Concurrent callers for the same GitHub token - * share a single in-flight mint; the caller's `AbortSignal` is - * deliberately NOT forwarded so cancelling one caller does not poison - * the shared mint for the others. - */ - private _getCopilotTokenEntry(githubToken: string): Promise { - const nowSeconds = Date.now() / 1000; - const existing = this._copilotTokensByGithub.get(githubToken); - if (existing) { - return existing.then(entry => { - if (entry.expiresAt - nowSeconds > COPILOT_TOKEN_REFRESH_BUFFER_SECONDS) { - return entry; - } - // Stale — evict only if the map still points at this - // promise. A concurrent caller may already have raced ahead - // and minted a fresh token; deleting unconditionally would - // evict that newer entry and cause a redundant re-mint. - if (this._copilotTokensByGithub.get(githubToken) === existing) { - this._copilotTokensByGithub.delete(githubToken); - } - return this._getCopilotTokenEntry(githubToken); - }).catch(err => { - if (this._copilotTokensByGithub.get(githubToken) === existing) { - this._copilotTokensByGithub.delete(githubToken); - } - throw err; - }); - } - - const pending: Promise = this._buildCopilotToken(githubToken).catch(err => { - if (this._copilotTokensByGithub.get(githubToken) === pending) { - this._copilotTokensByGithub.delete(githubToken); - } - throw err; - }); - this._copilotTokensByGithub.set(githubToken, pending); - return pending; - } - - private async _buildCopilotToken(githubToken: string): Promise { - const capiClient = await this._getClientForToken(githubToken); - - this._logService.debug('[CopilotApiService] Minting Copilot session token'); - - const request = { - method: 'GET', - headers: { - 'Authorization': `token ${githubToken}`, - 'X-GitHub-Api-Version': USER_API_VERSION, - }, - } as const; - const githubApiOverride = process.env[GITHUB_API_URL_OVERRIDE_ENV]; - const response = githubApiOverride && isAllowedCapiUrlOverride(githubApiOverride) - ? await this._fetch(`${githubApiOverride.replace(/\/$/, '')}/copilot_internal/v2/token`, request) - : await capiClient.makeRequest( - { - method: 'GET', - headers: request.headers, - }, - { type: RequestType.CopilotToken }, - ); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`Copilot session token mint failed: ${response.status} ${response.statusText} \u2014 ${text}`); - } - - const envelope = await response.json() as ICopilotTokenEnvelope; - if (typeof envelope.token !== 'string' || typeof envelope.expires_at !== 'number') { - throw new Error('Copilot session token mint returned malformed envelope'); - } - - // Prefer `now + refresh_in` over the server-reported `expires_at`: - // users with a fast local clock can see `expires_at` already in the - // past, which would cause us to re-mint on every call. Mirror what - // the Copilot Chat extension's `RefreshableCopilotTokenManager` - // does. Floor at `now + 60s` so a malformed/short `refresh_in` - // can't trigger a tight re-mint loop. - const nowSeconds = Date.now() / 1000; - const refreshIn = typeof envelope.refresh_in === 'number' ? envelope.refresh_in : undefined; - const organizationList = Array.isArray(envelope.organization_list) - ? envelope.organization_list.filter((organization): organization is string => typeof organization === 'string') - : []; - const expiresAt = Math.max( - refreshIn !== undefined ? nowSeconds + refreshIn : envelope.expires_at, - nowSeconds + 60, - ); - - return { - token: envelope.token, - expiresAt, - isInternal: organizationList.some(organization => INTERNAL_COPILOT_ORGANIZATIONS.has(organization)), - isVscodeTeamMember: organizationList.some(organization => VSCODE_COPILOT_ORGANIZATIONS.has(organization)), + restrictedTelemetryEnabled: envelope.restricted_telemetry === true, + trackingId: envelope.analytics_tracking_id, + isInternal: envelope.is_staff === true || internalOrganization !== undefined, + isVscodeTeamMember: internalOrganization === 'vscode', }; } diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index 414bed17323544..2e1c9aef4a0da0 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -293,6 +293,8 @@ export interface IIsolationConfigContribution { readonly worktreeIncludeFilesProperty: ISchemaProperty | undefined; /** Read-only carrier for the programmatic worktree branch tracking preference. */ readonly worktreeBranchTrackProperty: ISchemaProperty | undefined; + /** Read-only carrier for checking out the selected branch directly. */ + readonly worktreeCreateNewBranchProperty: ISchemaProperty | undefined; readonly isolationValue: 'folder' | 'worktree'; readonly branchDefault: string | undefined; readonly branchValue: string | undefined; @@ -475,6 +477,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI let worktreeBranchPrefixProperty: ISchemaProperty | undefined; let worktreeIncludeFilesProperty: ISchemaProperty | undefined; let worktreeBranchTrackProperty: ISchemaProperty | undefined; + let worktreeCreateNewBranchProperty: ISchemaProperty | undefined; if (gitInfo) { const branchReadOnly = isolationValue === 'folder'; branchDefault = isolationValue === 'worktree' ? gitInfo.defaultBranch.name : gitInfo.currentBranch; @@ -520,6 +523,15 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI sessionMutable: false, }); + worktreeCreateNewBranchProperty = schemaProperty({ + type: 'boolean', + title: localize('agentHost.sessionConfig.worktreeCreateNewBranch', "Create New Worktree Branch"), + description: localize('agentHost.sessionConfig.worktreeCreateNewBranchDescription', "Whether to create a new branch for the isolated worktree."), + default: true, + readOnly: true, + sessionMutable: false, + }); + worktreeIncludeFilesProperty = schemaProperty({ type: 'array', title: localize('agentHost.sessionConfig.worktreeIncludeFiles', "Worktree Include Files"), @@ -533,7 +545,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI }); } - return { isolationProperty, branchProperty, worktreeBranchPrefixProperty, worktreeBranchTrackProperty, worktreeIncludeFilesProperty, isolationValue, branchDefault, branchValue }; + return { isolationProperty, branchProperty, worktreeBranchPrefixProperty, worktreeBranchTrackProperty, worktreeCreateNewBranchProperty, worktreeIncludeFilesProperty, isolationValue, branchDefault, branchValue }; } /** @@ -563,7 +575,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI /** * Resolves the effective working directory for a session that is about to * be materialized. When the session config selects `worktree` isolation on - * a git repository, creates a fresh branch + worktree, records it for + * a git repository, creates or checks out a branch in a worktree, records it for * cleanup, queues the first-turn announcement, persists the worktree * metadata, and returns the worktree URI. Otherwise returns the requested * working directory unchanged. @@ -591,50 +603,69 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI } const repositoryRoot = await this._resolvePrimaryWorktreeRoot(checkoutRoot, checkoutRoot); - const worktreesRoot = getWorktreesRoot(repositoryRoot); + + const selectedBranch = config[SessionConfigKey.Branch] as string; + const worktreeBranchTrack = config[SessionConfigKey.WorktreeBranchTrack] === true; + const worktreeCreateNewBranch = config[SessionConfigKey.WorktreeCreateNewBranch] !== false; + // Prefix (e.g. the user's `git.branchPrefix`) the client forwards for // worktree-isolated sessions. Prepended ahead of the built-in `agents/` // prefix when naming the branch and stripped from the worktree dir name. - const worktreeBranchPrefix = typeof config[SessionConfigKey.WorktreeBranchPrefix] === 'string' + const worktreeBranchPrefix = worktreeCreateNewBranch && typeof config[SessionConfigKey.WorktreeBranchPrefix] === 'string' ? config[SessionConfigKey.WorktreeBranchPrefix] as string : undefined; - const selectedBranch = config[SessionConfigKey.Branch] as string; - const { branchName, worktree, baseBranch } = await this._worktreeCreationSequencer.queue(repositoryRoot.toString(), async () => { - onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.NamingBranch)); - const branchName = await this._branchNameGenerator.generateBranchName({ - sessionId, - message: prompt, - githubToken, - branchPrefix: worktreeBranchPrefix, - branchNameCollides: async candidate => { - if (await this._gitService.branchExists(repositoryRoot, candidate).catch(() => true)) { - return true; - } - const candidateWorktree = URI.joinPath(worktreesRoot, getWorktreeName(candidate, worktreeBranchPrefix)); - return fileExists(candidateWorktree.fsPath); - }, - }); - const worktree = URI.joinPath(worktreesRoot, getWorktreeName(branchName, worktreeBranchPrefix)); - const baseBranch = await this._resolveBranchStartPoint(repositoryRoot, selectedBranch); - await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); + + const { worktreePath, branchName, baseBranch } = await this._worktreeCreationSequencer.queue(repositoryRoot.toString(), async () => { + const worktreesRoot = getWorktreesRoot(repositoryRoot); + + if (worktreeCreateNewBranch) { + onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.NamingBranch)); + } + const newBranchName = worktreeCreateNewBranch + ? await this._branchNameGenerator.generateBranchName({ + sessionId, + message: prompt, + githubToken, + branchPrefix: worktreeBranchPrefix, + branchNameCollides: async candidate => { + if (await this._gitService.branchExists(repositoryRoot, candidate).catch(() => true)) { + return true; + } + const candidateWorktree = URI.joinPath(worktreesRoot, getWorktreeName(candidate, worktreeBranchPrefix)); + return fileExists(candidateWorktree.fsPath); + }, + }) + : undefined; + + const branchStartPoint = await this._resolveBranchStartPoint(repositoryRoot, selectedBranch); + + const baseBranch = worktreeCreateNewBranch + ? branchStartPoint + : (await this._gitService.getDefaultBranch(repositoryRoot))?.startPoint; // Git suppresses progress for the first couple of seconds, so name // the phase up front rather than leaving the label stale until the // first percentage arrives. onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CheckingOut)); - const worktreeBranchTrack = config[SessionConfigKey.WorktreeBranchTrack] === true; + await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); + const worktreePath = URI.joinPath(worktreesRoot, getWorktreeName(newBranchName ?? selectedBranch, worktreeBranchPrefix)); + await withPercentProgress(WorktreeCreationPhase.CheckingOut, onProgress, progress => this._gitService.addWorktree(repositoryRoot, { - path: worktree, - commitish: baseBranch, - newBranchName: branchName, + path: worktreePath, + commitish: worktreeCreateNewBranch + ? branchStartPoint + : selectedBranch, + newBranchName, + preferRemoteBranch: worktreeCreateNewBranch, track: worktreeBranchTrack, - preferRemoteBranch: true, onProgress: progress, })); - return { branchName, worktree, baseBranch }; + + return { branchName: newBranchName ?? selectedBranch, worktreePath, baseBranch }; }); + const worktreeIncludeFiles = Array.isArray(config[SessionConfigKey.WorktreeIncludeFiles]) && config[SessionConfigKey.WorktreeIncludeFiles].every(pattern => typeof pattern === 'string') ? config[SessionConfigKey.WorktreeIncludeFiles] as readonly string[] @@ -643,21 +674,25 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI try { onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CopyingIncludeFiles)); await withPercentProgress(WorktreeCreationPhase.CopyingIncludeFiles, onProgress, progress => - this._gitService.copyWorktreeIncludeFiles(checkoutRoot, worktree, worktreeIncludeFiles, progress)); + this._gitService.copyWorktreeIncludeFiles(checkoutRoot, worktreePath, worktreeIncludeFiles, progress)); } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to copy worktree include files: ${errorMessage(error)}`); } } - this._materializedWorktrees.set(sessionId, { repositoryRoot, worktree }); + + this._materializedWorktrees.set(sessionId, { repositoryRoot, worktree: worktreePath }); + // Queue the worktree announcement so the first turn (live) and any // subsequent restore (history) both surface the message in the chat. this._pendingFirstTurnAnnouncements.set(sessionId, buildWorktreeAnnouncementText(branchName)); + try { - await this._writeWorktreeMetadata(sessionUri, { branchName, baseBranch, worktreePath: worktree, repositoryRoot }); + await this._writeWorktreeMetadata(sessionUri, { repositoryRoot, worktreePath, baseBranch, branchName }); } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to persist worktree branch metadata: ${errorMessage(error)}`); } - return worktree; + + return worktreePath; } /** Resolves a persisted working directory, repairing a removed worktree when possible. */ diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index 2ec23a09a137d1..f926bedd2e4c97 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -294,10 +294,10 @@ suite('AgentHostCommitOperationHandler', () => { }); }); - test('maps Copilot token mint auth failures to AHP_AUTH_REQUIRED before committing', async () => { + test('maps Copilot API auth failures to AHP_AUTH_REQUIRED before committing', async () => { const gitService = new TestGitService(); const copilotApiService = new TestCopilotApiService(); - copilotApiService.error = new Error('Copilot session token mint failed: 403 Forbidden'); + copilotApiService.error = new Error('Copilot API authorization failed: 403 Forbidden'); const changesets = new TestChangesetService(); const { handler, session, committedSessions } = setup(disposables, gitService, copilotApiService, changesets); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index a74bd772cfd09f..59e92517d4e58d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -764,6 +764,68 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { } }); + (hasGit ? test : test.skip)('addWorktree preserves tracking when attaching an existing branch', async () => { + const dir = initRepo(); + const remotePath = join(dir, 'remote.git'); + cp.execFileSync('git', ['init', '--bare', '-q', remotePath], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['remote', 'add', 'origin', remotePath], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['push', '-q', 'origin', 'main'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['branch', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['push', '-q', '--set-upstream', 'origin', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + const wtPath = join(dir, '..', `wt-${Date.now()}`); + try { + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'feature', + track: true, + }); + + assert.deepStrictEqual({ + branch: cp.execFileSync('git', ['branch', '--show-current'], { cwd: wtPath, env, encoding: 'utf8' }).trim(), + upstream: cp.execFileSync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd: wtPath, env, encoding: 'utf8' }).trim(), + }, { + branch: 'feature', + upstream: 'origin/feature', + }); + } finally { + try { await svc!.removeWorktree(URI.file(dir), URI.file(wtPath), { force: true }); } catch { /* best-effort cleanup */ } + rmDirWithRetry(wtPath); + } + }); + + (hasGit ? test : test.skip)('addWorktree automatically tracks a remote branch when creating its local branch', async () => { + const dir = initRepo(); + const remotePath = join(dir, 'remote.git'); + cp.execFileSync('git', ['init', '--bare', '-q', remotePath], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['remote', 'add', 'origin', remotePath], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['checkout', '-q', '-b', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['commit', '-q', '--allow-empty', '-m', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['push', '-q', 'origin', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['checkout', '-q', 'main'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['branch', '-D', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + const wtPath = join(dir, '..', `wt-${Date.now()}`); + try { + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'feature', + newBranchName: 'feature', + track: true, + preferRemoteBranch: true, + }); + + assert.deepStrictEqual({ + branch: cp.execFileSync('git', ['branch', '--show-current'], { cwd: wtPath, env, encoding: 'utf8' }).trim(), + upstream: cp.execFileSync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd: wtPath, env, encoding: 'utf8' }).trim(), + }, { + branch: 'feature', + upstream: 'origin/feature', + }); + } finally { + try { await svc!.removeWorktree(URI.file(dir), URI.file(wtPath), { force: true }); } catch { /* best-effort cleanup */ } + rmDirWithRetry(wtPath); + } + }); + (hasGit ? test : test.skip)('removeWorktree preserves dirty work unless forced', async () => { const dir = initRepo(); const fs = await import('fs/promises'); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 84fed362bde43e..0e46fd3295991f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -210,7 +210,7 @@ suite('AgentHostGitStateService', () => { }; } - function seedSession(stateManager: AgentHostStateManager, options?: { workingDirectory?: string; project?: string; gitState?: ISessionGitState; gitHubState?: ISessionGitHubState; isolation?: 'folder' | 'worktree'; baseBranch?: string; createdAt?: number }): void { + function seedSession(stateManager: AgentHostStateManager, options?: { workingDirectory?: string; project?: string; gitState?: ISessionGitState; gitHubState?: ISessionGitHubState; isolation?: 'folder' | 'worktree'; baseBranch?: string; createNewBranch?: boolean; createdAt?: number }): void { const summary: SessionSummary = { resource: SESSION, provider: 'mock', @@ -230,6 +230,7 @@ suite('AgentHostGitStateService', () => { values: { [SessionConfigKey.Isolation]: options.isolation, ...(options.baseBranch ? { [SessionConfigKey.Branch]: options.baseBranch } : {}), + ...(options.createNewBranch !== undefined ? { [SessionConfigKey.WorktreeCreateNewBranch]: options.createNewBranch } : {}), }, }); } @@ -314,6 +315,23 @@ suite('AgentHostGitStateService', () => { assert.deepStrictEqual(h.gitBaseBranches, ['release']); })); + test('uses the persisted base branch when the selected branch is checked out directly', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + seedSession(h.stateManager, { + workingDirectory: WORKING_DIRECTORY, + project: 'file:///repo', + isolation: 'worktree', + baseBranch: 'feature/pr', + createNewBranch: false, + }); + await h.db.setMetadata(META_DIFF_BASE_BRANCH, 'origin/main'); + h.setGitResult({ branchName: 'feature/pr', baseBranchName: 'main' }); + + await h.service.refreshSessionGitState(SESSION, undefined); + + assert.deepStrictEqual(h.gitBaseBranches, ['main']); + })); + test('uses the persisted worktree base branch for an adopted linked worktree', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const h = createHarness(); seedSession(h.stateManager, { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index bd3d854d6ea1db..9b5136b6f4b05e 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -36,6 +36,7 @@ import { ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js' import { CodexSessionConfigKey } from '../../common/codexSessionConfigKeys.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agentHostGitStateService.js'; +import { GitRefType } from '../../common/agentHostGitService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; @@ -783,6 +784,7 @@ suite('AgentService (node dispatcher)', () => { [SessionConfigKey.WorktreeBranchPrefix]: 'users/test/', [SessionConfigKey.WorktreeIncludeFiles]: ['.env'], [SessionConfigKey.WorktreeBranchTrack]: false, + [SessionConfigKey.WorktreeCreateNewBranch]: false, providerSetting: 'selected', }, }); @@ -800,6 +802,7 @@ suite('AgentService (node dispatcher)', () => { [SessionConfigKey.WorktreeBranchPrefix]: 'users/test/', [SessionConfigKey.WorktreeIncludeFiles]: ['.env'], [SessionConfigKey.WorktreeBranchTrack]: false, + [SessionConfigKey.WorktreeCreateNewBranch]: false, providerSetting: 'completion', }, property: 'providerSetting', @@ -822,6 +825,7 @@ suite('AgentService (node dispatcher)', () => { branchPrefix: selected.values[SessionConfigKey.WorktreeBranchPrefix], includeFiles: selected.values[SessionConfigKey.WorktreeIncludeFiles], branchTrack: selected.values[SessionConfigKey.WorktreeBranchTrack], + createNewBranch: selected.values[SessionConfigKey.WorktreeCreateNewBranch], providerSetting: selected.values.providerSetting, }, folder: { @@ -844,7 +848,7 @@ suite('AgentService (node dispatcher)', () => { agentMergeController: { lastPromptFingerprint: 'fingerprint' }, providerSetting: 'initial', }, - selected: { isolation: 'worktree', branch: 'feature/config', branchPrefix: 'users/test/', includeFiles: ['.env'], branchTrack: false, providerSetting: 'selected' }, + selected: { isolation: 'worktree', branch: 'feature/config', branchPrefix: 'users/test/', includeFiles: ['.env'], branchTrack: false, createNewBranch: false, providerSetting: 'selected' }, folder: { isolation: 'folder', branch: 'feature', providerSetting: 'folder' }, }); }); @@ -12341,6 +12345,107 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString()); }); + test('pending worktree session defers git state and branch changes until materialization', async () => { + class ProvisionalWorktreeAgent extends MockAgent { + private readonly _onDidMaterializeChat = new Emitter(); + override readonly onDidMaterializeChat = this._onDidMaterializeChat.event; + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + createChat: (chat, context, options) => createProvisionalChat(base, chat, context, options), + })); + + materialize(session: URI, workingDirectory: URI): void { + this._onDidMaterializeChat.fire({ + chat: URI.parse(buildDefaultChatUri(session)), + workingDirectories: [workingDirectory], + project: undefined, + }); + } + + override dispose(): void { + this._onDidMaterializeChat.dispose(); + super.dispose(); + } + } + + const sourceDir = URI.file('/source/repo'); + const worktreeDir = URI.file('/source/repo.worktrees/feature'); + const gitStateCalls: Array<{ resource: string; baseBranch: string | undefined }> = []; + const diffCalls: string[] = []; + const gitService = createNoopGitService(); + gitService.getRepositoryRoot = async () => sourceDir; + gitService.revParse = async () => 'head'; + gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' }); + gitService.getCurrentBranch = async () => 'main'; + gitService.getBranches = async () => [{ ref: 'refs/heads/main', name: 'main', kind: GitRefType.Head }]; + gitService.getSessionGitState = async (resource, baseBranch) => { + gitStateCalls.push({ resource: resource.toString(), baseBranch }); + return { branchName: 'feature', baseBranchName: 'main' }; + }; + gitService.computeSessionFileDiffs = async resource => { + diffCalls.push(resource.toString()); + return []; + }; + + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const isolation = disposables.add(new WorktreeIsolation( + { generateBranchName: async () => { throw new Error('should not generate a branch'); } }, + gitService, + new TestCopilotApiService(), + nullSessionDataService, + new NullLogService(), + )); + localService.setWorktreeIsolation(isolation); + const agent = new ProvisionalWorktreeAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + localService.registerProvider(agent); + + const session = await localService.createSession({ + provider: agent.id, + workingDirectories: [sourceDir], + config: { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: 'feature', + [SessionConfigKey.WorktreeCreateNewBranch]: false, + }, + }); + const branchChangeset = buildBranchChangesetUri(session.toString()); + localService.addSubscriber(URI.parse(branchChangeset), 'client-1'); + await timeout(0); + + const beforeMaterialization = { + workingDirectory: localService.stateManager.getSessionState(session.toString())?.workingDirectories?.[0], + gitStateCalls: [...gitStateCalls], + diffCalls: [...diffCalls], + }; + + isolation.clearPending(AgentSession.id(session)); + agent.materialize(session, worktreeDir); + for (let i = 0; i < 20 && (gitStateCalls.length === 0 || diffCalls.length === 0); i++) { + await timeout(0); + } + + assert.deepStrictEqual({ + beforeMaterialization, + afterMaterialization: { + workingDirectory: localService.stateManager.getSessionState(session.toString())?.workingDirectories?.[0], + gitStateCalls, + diffCalls: [...new Set(diffCalls)], + }, + }, { + beforeMaterialization: { + workingDirectory: sourceDir.toString(), + gitStateCalls: [], + diffCalls: [], + }, + afterMaterialization: { + workingDirectory: worktreeDir.toString(), + gitStateCalls: [{ resource: worktreeDir.toString(), baseBranch: undefined }], + diffCalls: [worktreeDir.toString()], + }, + }); + localService.unsubscribe(URI.parse(branchChangeset), 'client-1'); + }); + test('_resolveWorkingDirectoryBeforeSend returns the full set (index 0 + tail), or undefined when unset', async () => { const resolver = service as unknown as { _resolveWorkingDirectoryBeforeSend: (p: { session: string; chat: string; turnId: string; prompt: string }) => Promise; diff --git a/src/vs/platform/agentHost/test/node/copilotTokenFields.test.ts b/src/vs/platform/agentHost/test/node/copilotTokenFields.test.ts deleted file mode 100644 index a9d124e425bc6a..00000000000000 --- a/src/vs/platform/agentHost/test/node/copilotTokenFields.test.ts +++ /dev/null @@ -1,77 +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 assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { isRestrictedTelemetryEnabled, parseCopilotTokenFields } from '../../node/copilot/copilotTokenFields.js'; - -suite('copilotTokenFields', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - suite('parseCopilotTokenFields', () => { - test('returns empty map for undefined token', () => { - assert.strictEqual(parseCopilotTokenFields(undefined).size, 0); - }); - - test('returns empty map for empty token', () => { - assert.strictEqual(parseCopilotTokenFields('').size, 0); - }); - - test('parses fields from the leading colon-delimited segment', () => { - const fields = parseCopilotTokenFields('tid=abc;exp=123;rt=1:HMACSIGNATURE'); - assert.strictEqual(fields.get('tid'), 'abc'); - assert.strictEqual(fields.get('exp'), '123'); - assert.strictEqual(fields.get('rt'), '1'); - }); - - test('parses fields when no colon separator is present', () => { - const fields = parseCopilotTokenFields('tid=abc;rt=1'); - assert.strictEqual(fields.get('tid'), 'abc'); - assert.strictEqual(fields.get('rt'), '1'); - }); - - test('skips segments without a value separator', () => { - const fields = parseCopilotTokenFields('tid=abc;rt;exp=123:HMAC'); - assert.strictEqual(fields.has('rt'), false); - assert.strictEqual(fields.get('tid'), 'abc'); - assert.strictEqual(fields.get('exp'), '123'); - }); - }); - - suite('isRestrictedTelemetryEnabled', () => { - test('false for undefined token', () => { - assert.strictEqual(isRestrictedTelemetryEnabled(undefined), false); - }); - - test('false for empty token', () => { - assert.strictEqual(isRestrictedTelemetryEnabled(''), false); - }); - - test('false when rt field is missing', () => { - assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;exp=123:HMAC'), false); - }); - - test('false when rt=0', () => { - assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=0;exp=123:HMAC'), false); - }); - - test('true when rt=1 with other fields', () => { - assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=1;exp=123:HMAC'), true); - }); - - test('true when rt=1 is the first field', () => { - assert.strictEqual(isRestrictedTelemetryEnabled('rt=1;tid=abc:HMAC'), true); - }); - - test('true when rt=1 is the last field', () => { - assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;exp=123;rt=1:HMAC'), true); - }); - - test('true when token has no colon-delimited signature segment', () => { - assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=1'), true); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts index 33e7dfae9bd790..ded159a2b90bc8 100644 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts @@ -62,7 +62,7 @@ suite('CopilotApiService.utilityChatCompletion (real CAPI)', () => { assert.strictEqual(answer.trim().toLowerCase(), 'olleh'); }); - (hasToken ? test : test.skip)('caches the Copilot session token across calls', async function () { + (hasToken ? test : test.skip)('reuses endpoint and model discovery across calls', async function () { this.timeout(60_000); const service = createService(); @@ -73,10 +73,8 @@ suite('CopilotApiService.utilityChatCompletion (real CAPI)', () => { messages: [{ role: 'user', content: 'Say "ok" and nothing else.' }], }); - // Both calls succeed; the second is served from the cached - // Copilot token + resolved model id. Cache-hit assertions live in - // the unit-test suite (see copilotApiService.test.ts) where we can - // count `RequestType.CopilotToken` calls against a fake fetch. + // Both calls succeed; the second reuses endpoint and model discovery. + // Cache-hit assertions live in the unit-test suite. assert.ok(first.toLowerCase().includes('ok')); assert.ok(second.toLowerCase().includes('ok')); }); diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts index 1ab08c5a79db4b..71c71452ba29d0 100644 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts @@ -50,18 +50,10 @@ function getText(msg: Anthropic.Message): string { .join(''); } -function tokenResponse(overrides?: Record): Response { - return new Response(JSON.stringify({ - token: 'copilot-tok-abc', - expires_at: Date.now() / 1000 + 3600, - refresh_in: 1800, - ...overrides, - }), { status: 200 }); -} - -function userResponse(): Response { +function userResponse(overrides?: Record): Response { return new Response(JSON.stringify({ endpoints: { api: 'https://api.githubcopilot.com' }, + ...overrides, }), { status: 200 }); } @@ -99,13 +91,13 @@ type CapturedRequest = { url: string; init: RequestInit | undefined }; function routingFetch( messageResponse: (captured: CapturedRequest) => Response, - tokenOverrides?: Record, + userOverrides?: Record, ): { fetch: FetchFunction; captured: () => CapturedRequest } { let lastCapture: CapturedRequest = { url: '', init: undefined }; const impl: FetchFunction = async (input, init) => { const url = getUrl(input); - if (url.includes('/token') || url.includes('/copilot_internal')) { - return tokenResponse(tokenOverrides); + if (url.endsWith('/copilot_internal/user')) { + return userResponse(userOverrides); } lastCapture = { url, init }; return messageResponse(lastCapture); @@ -132,36 +124,104 @@ suite('CopilotApiService', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('combines internal organizations from the Copilot token with login from user discovery', async () => { + test('derives restricted telemetry context from user discovery without minting a Copilot token', async () => { + const requests: string[] = []; const service = createService(async input => { const url = getUrl(input); + requests.push(new URL(url).pathname); if (url.endsWith('/copilot_internal/user')) { return new Response(JSON.stringify({ login: 'octocat', copilotignore_enabled: true, + restricted_telemetry: true, + analytics_tracking_id: 'tracking-id', + organization_login_list: ['microsoft', 'Visual-Studio-Code'], endpoints: { api: 'https://api.githubcopilot.com', telemetry: 'https://telemetry.example' }, }), { status: 200 }); } - if (url.includes('/token')) { - return tokenResponse({ - token: 'rt=1;tid=tracking-id', - organization_list: [ - 'a5db0bcaae94032fe715fb34a5e4bce2', - '551cca60ce19654d894e786220822482', - ], - }); + throw new Error(`Unexpected request: ${url}`); + }); + + assert.deepStrictEqual({ + context: await service.resolveRestrictedTelemetryContext('gh-token'), + requests, + }, { + context: { + restrictedTelemetryEnabled: true, + trackingId: 'tracking-id', + telemetryEndpoint: 'https://telemetry.example', + isInternal: true, + userName: 'octocat', + isVscodeTeamMember: true, + copilotIgnoreEnabled: true, + }, + requests: ['/copilot_internal/user'], + }); + }); + + test('keeps restricted telemetry disabled when user discovery does not opt in', async () => { + const service = createService(async input => { + const url = getUrl(input); + if (url.endsWith('/copilot_internal/user')) { + return new Response(JSON.stringify({ + restricted_telemetry: false, + analytics_tracking_id: 'tracking-id', + endpoints: { telemetry: 'https://telemetry.example' }, + }), { status: 200 }); } throw new Error(`Unexpected request: ${url}`); }); assert.deepStrictEqual(await service.resolveRestrictedTelemetryContext('gh-token'), { - restrictedTelemetryEnabled: true, + restrictedTelemetryEnabled: false, trackingId: 'tracking-id', - telemetryEndpoint: 'https://telemetry.example', + telemetryEndpoint: undefined, + isInternal: false, + userName: undefined, + isVscodeTeamMember: false, + copilotIgnoreEnabled: undefined, + }); + }); + + test('recognizes all internal organization login aliases from user discovery', async () => { + const contexts = await Promise.all(['github', 'microsoft', 'ms-copilot', 'MicrosoftCopilot'].map(async organization => { + const service = createService(async input => { + const url = getUrl(input); + if (url.endsWith('/copilot_internal/user')) { + return userResponse({ organization_login_list: [organization] }); + } + throw new Error(`Unexpected request: ${url}`); + }); + return service.resolveRestrictedTelemetryContext(`gh-token-${organization}`); + })); + + assert.deepStrictEqual(contexts.map(context => ({ + isInternal: context.isInternal, + isVscodeTeamMember: context.isVscodeTeamMember, + })), [ + { isInternal: true, isVscodeTeamMember: false }, + { isInternal: true, isVscodeTeamMember: false }, + { isInternal: true, isVscodeTeamMember: false }, + { isInternal: true, isVscodeTeamMember: false }, + ]); + }); + + test('recognizes staff without an internal organization', async () => { + const service = createService(async input => { + const url = getUrl(input); + if (url.endsWith('/copilot_internal/user')) { + return userResponse({ is_staff: true }); + } + throw new Error(`Unexpected request: ${url}`); + }); + + const context = await service.resolveRestrictedTelemetryContext('gh-token'); + assert.deepStrictEqual({ + isInternal: context.isInternal, + isVscodeTeamMember: context.isVscodeTeamMember, + }, { isInternal: true, - userName: 'octocat', - isVscodeTeamMember: true, - copilotIgnoreEnabled: true, + isVscodeTeamMember: false, }); }); @@ -170,27 +230,27 @@ suite('CopilotApiService', () => { suite('Endpoint Discovery', () => { test('runs endpoint discovery on first request', async () => { - let mintCount = 0; + let discoveryCount = 0; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); + discoveryCount++; + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'hi' }]); }); await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 1); + assert.strictEqual(discoveryCount, 1); }); test('reuses cached endpoint discovery for consecutive calls with same github token', async () => { - let mintCount = 0; + let discoveryCount = 0; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); + discoveryCount++; + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'hi' }]); }); @@ -198,33 +258,33 @@ suite('CopilotApiService', () => { await service.messages('gh-tok', baseRequest); await service.messages('gh-tok', baseRequest); await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 1); + assert.strictEqual(discoveryCount, 1); }); test('re-discovers endpoints when the github token changes', async () => { - let mintCount = 0; + let discoveryCount = 0; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); + discoveryCount++; + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'hi' }]); }); await service.messages('gh-tok-A', baseRequest); await service.messages('gh-tok-B', baseRequest); - assert.strictEqual(mintCount, 2); + assert.strictEqual(discoveryCount, 2); }); test('invalidates cached endpoint discovery on 401 from messages so the next call re-discovers', async () => { - let mintCount = 0; + let discoveryCount = 0; let messageCallCount = 0; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); + discoveryCount++; + return userResponse(); } messageCallCount++; if (messageCallCount === 1) { @@ -235,17 +295,17 @@ suite('CopilotApiService', () => { await assert.rejects(() => service.messages('gh-tok', baseRequest)); await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 2); + assert.strictEqual(discoveryCount, 2); }); test('invalidates cached endpoint discovery on 403 from models so the next call re-discovers', async () => { - let mintCount = 0; + let discoveryCount = 0; let modelsCallCount = 0; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); + discoveryCount++; + return userResponse(); } modelsCallCount++; if (modelsCallCount === 1) { @@ -256,23 +316,23 @@ suite('CopilotApiService', () => { await assert.rejects(() => service.models('gh-tok')); await service.models('gh-tok'); - assert.strictEqual(mintCount, 2); + assert.strictEqual(discoveryCount, 2); }); test('does not re-discover when the cache is still warm for the same token', async () => { - let mintCount = 0; + let discoveryCount = 0; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse({ expires_at: Date.now() / 1000 + 7200 }); + discoveryCount++; + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'hi' }]); }); await service.messages('gh-tok', baseRequest); await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 1); + assert.strictEqual(discoveryCount, 1); }); test('uses endpoints.api from the /copilot_internal/user response as the CAPI base', async () => { @@ -330,7 +390,7 @@ suite('CopilotApiService', () => { if (url.includes('/copilot_internal')) { const headers = init?.headers as Record; capturedAuthHeader = headers?.['Authorization']; - return tokenResponse(); + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'ok' }]); }); @@ -345,7 +405,7 @@ suite('CopilotApiService', () => { const url = getUrl(input); if (url.includes('/copilot_internal')) { discoveryUrl = url; - return tokenResponse(); + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'ok' }]); }, 'https://acme.ghe.com'); @@ -391,13 +451,13 @@ suite('CopilotApiService', () => { }); test('does not double-discover when concurrent requests race on first call', async () => { - let mintCount = 0; + let discoveryCount = 0; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; + discoveryCount++; await new Promise(r => setTimeout(r, 10)); // ensure overlap - return tokenResponse(); + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'ok' }]); }); @@ -406,17 +466,17 @@ suite('CopilotApiService', () => { service.messages('gh-tok', baseRequest), service.messages('gh-tok', baseRequest), ]); - assert.strictEqual(mintCount, 1); + assert.strictEqual(discoveryCount, 1); }); test('in-flight discovery dedup spans concurrent messages + models calls', async () => { - let mintCount = 0; + let discoveryCount = 0; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; + discoveryCount++; await new Promise(r => setTimeout(r, 10)); - return tokenResponse(); + return userResponse(); } if (url.includes('/models')) { return modelsResponse([]); @@ -428,7 +488,7 @@ suite('CopilotApiService', () => { service.messages('gh-tok', baseRequest), service.models('gh-tok'), ]); - assert.strictEqual(mintCount, 1); + assert.strictEqual(discoveryCount, 1); }); test('error from endpoint discovery does not include the github token', async () => { @@ -443,25 +503,25 @@ suite('CopilotApiService', () => { const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse({ token: 'super-secret-copilot-token-xyz' }); + return userResponse(); } return new Response('rate limited', { status: 429, statusText: 'Too Many Requests' }); }); await assert.rejects( () => service.messages('super-secret-gh-token-xyz', baseRequest), - (err: Error) => !err.message.includes('super-secret-copilot-token-xyz') && !err.message.includes('super-secret-gh-token-xyz'), + (err: Error) => !err.message.includes('super-secret-gh-token-xyz'), ); }); test('discovers independently for concurrent requests with different github tokens', async () => { - const minted: string[] = []; + const authorizationHeaders: string[] = []; const service = createService(async (input, init) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { const auth = (init?.headers as Record)?.['Authorization'] ?? ''; - minted.push(auth); + authorizationHeaders.push(auth); await new Promise(r => setTimeout(r, 10)); // ensure overlap - return tokenResponse(); + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'ok' }]); }); @@ -470,9 +530,9 @@ suite('CopilotApiService', () => { service.messages('gh-tok-A', baseRequest), service.messages('gh-tok-B', baseRequest), ]); - assert.strictEqual(minted.length, 2); - assert.ok(minted.some(h => h.includes('gh-tok-A'))); - assert.ok(minted.some(h => h.includes('gh-tok-B'))); + assert.strictEqual(authorizationHeaders.length, 2); + assert.ok(authorizationHeaders.some(header => header.includes('gh-tok-A'))); + assert.ok(authorizationHeaders.some(header => header.includes('gh-tok-B'))); }); suite('CAPI URL override (VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE)', () => { @@ -506,7 +566,7 @@ suite('CopilotApiService', () => { const url = getUrl(input); if (url.includes('/copilot_internal')) { discoveryHit = true; - return tokenResponse(); + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'ok' }]); }); @@ -524,7 +584,7 @@ suite('CopilotApiService', () => { const url = getUrl(input); if (url.includes('/copilot_internal')) { discoveryHit = true; - return tokenResponse(); + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'ok' }]); }); @@ -542,7 +602,7 @@ suite('CopilotApiService', () => { const url = getUrl(input); if (url.includes('/copilot_internal')) { discoveryHit = true; - return tokenResponse(); + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'ok' }]); }); @@ -601,7 +661,7 @@ suite('CopilotApiService', () => { const service = createService(async (input, init) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } if (url.endsWith('/models')) { return modelsResponse([{ id: 'gpt-4o-mini-model', capabilities: { family: 'gpt-4o-mini' } }]); @@ -1224,13 +1284,13 @@ suite('CopilotApiService', () => { ); }); - test('does not mint a token before throwing', async () => { - let mintCount = 0; + test('does not discover endpoints before throwing', async () => { + let discoveryCount = 0; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); + discoveryCount++; + return userResponse(); } return new Response('{}', { status: 200 }); }); @@ -1238,7 +1298,7 @@ suite('CopilotApiService', () => { await assert.rejects( () => service.countTokens('gh-tok', { model: 'claude-sonnet-4-5', messages: [{ role: 'user', content: 'hi' }] }), ); - assert.strictEqual(mintCount, 0); + assert.strictEqual(discoveryCount, 0); }); }); @@ -1253,7 +1313,7 @@ suite('CopilotApiService', () => { const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } urls.push(url); if (urls.length === 1) { @@ -1270,20 +1330,20 @@ suite('CopilotApiService', () => { assert.ok(urls[1].endsWith('/v1/messages')); }); - test('both modes share the same cached copilot token', async () => { - let mintCount = 0; + test('both modes share the same cached endpoint discovery', async () => { + let discoveryCount = 0; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); + discoveryCount++; + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'ok' }]); }); await service.messages('gh-tok', baseRequest); await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 1); + assert.strictEqual(discoveryCount, 1); }); }); @@ -1522,25 +1582,24 @@ suite('CopilotApiService', () => { const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse({ token: 'super-secret-copilot-token-xyz' }); + return userResponse(); } return new Response('rate limited', { status: 429, statusText: 'Too Many Requests' }); }); const err = await captureCopilotApiError(service.messages('super-secret-gh-token-xyz', baseRequest)); const serialized = JSON.stringify({ message: err.message, envelope: err.envelope }); - assert.ok(!serialized.includes('super-secret-copilot-token-xyz')); - assert.ok(!serialized.includes('super-secret-gh-token-xyz')); + assert.strictEqual(serialized.includes('super-secret-gh-token-xyz'), false); }); - test('401 still invalidates the cached token (regression)', async () => { - let mintCount = 0; + test('401 still invalidates cached endpoint discovery', async () => { + let discoveryCount = 0; let next401 = true; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); + discoveryCount++; + return userResponse(); } if (next401) { next401 = false; @@ -1551,7 +1610,7 @@ suite('CopilotApiService', () => { await captureCopilotApiError(service.messages('gh-tok', baseRequest)); await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 2); + assert.strictEqual(discoveryCount, 2); }); }); @@ -1567,7 +1626,7 @@ suite('CopilotApiService', () => { const service = createService(async (input, init) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } capturedSignal = init?.signal as AbortSignal; return anthropicResponse([{ type: 'text', text: 'ok' }]); @@ -1583,7 +1642,7 @@ suite('CopilotApiService', () => { const service = createService(async (input, init) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } capturedSignal = init?.signal as AbortSignal; return modelsResponse([]); @@ -1593,20 +1652,20 @@ suite('CopilotApiService', () => { assert.strictEqual(capturedSignal, controller.signal); }); - test('does not forward AbortSignal to the shared token mint fetch', async () => { + test('does not forward AbortSignal to shared endpoint discovery', async () => { const controller = new AbortController(); - let mintSignal: AbortSignal | undefined; + let discoverySignal: AbortSignal | undefined; const service = createService(async (input, init) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintSignal = init?.signal as AbortSignal; - return tokenResponse(); + discoverySignal = init?.signal as AbortSignal; + return userResponse(); } return anthropicResponse([{ type: 'text', text: 'ok' }]); }); await service.messages('gh-tok', baseRequest, { signal: controller.signal }); - assert.strictEqual(mintSignal, undefined); + assert.strictEqual(discoverySignal, undefined); }); test('cancels the underlying SSE stream when the consumer breaks early', async () => { @@ -1624,7 +1683,7 @@ suite('CopilotApiService', () => { const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); }); @@ -1654,7 +1713,7 @@ suite('CopilotApiService', () => { const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); }); @@ -1678,7 +1737,7 @@ suite('CopilotApiService', () => { const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); }); @@ -1702,7 +1761,7 @@ suite('CopilotApiService', () => { const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } return modelsResponse(fakeModels); }); @@ -1715,7 +1774,7 @@ suite('CopilotApiService', () => { const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } return new Response(JSON.stringify({}), { status: 200 }); }); @@ -1729,7 +1788,7 @@ suite('CopilotApiService', () => { const service = createService(async (input, init) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } capturedAuthHeader = (init?.headers as Record)?.['Authorization']; return modelsResponse([]); @@ -1743,7 +1802,7 @@ suite('CopilotApiService', () => { const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } return new Response('forbidden', { status: 403, statusText: 'Forbidden' }); }); @@ -1756,13 +1815,13 @@ suite('CopilotApiService', () => { ); }); - test('reuses cached token across messages and models calls', async () => { - let mintCount = 0; + test('reuses cached endpoint discovery across messages and models calls', async () => { + let discoveryCount = 0; const service = createService(async (input) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); + discoveryCount++; + return userResponse(); } if (url.includes('/models')) { return modelsResponse([]); @@ -1772,7 +1831,7 @@ suite('CopilotApiService', () => { await service.messages('gh-tok', baseRequest); await service.models('gh-tok'); - assert.strictEqual(mintCount, 1); + assert.strictEqual(discoveryCount, 1); }); test('routes to the models endpoint URL', async () => { @@ -1788,7 +1847,7 @@ suite('CopilotApiService', () => { const service = createService(async (input, init) => { const url = getUrl(input); if (url.includes('/copilot_internal')) { - return tokenResponse(); + return userResponse(); } capturedHeaders = init?.headers as Record; return modelsResponse([]); diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index f4e59d4424915d..3ec01b3e73c0ab 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -152,17 +152,17 @@ suite('WorktreeIsolation', () => { const noCommits = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: undefined }); assert.deepStrictEqual({ - noRepo: { enum: noRepo.isolationProperty.protocol.enum, value: noRepo.isolationValue, branch: noRepo.branchProperty, prefix: noRepo.worktreeBranchPrefixProperty, includeFiles: noRepo.worktreeIncludeFilesProperty, branchTrack: noRepo.worktreeBranchTrackProperty }, - repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly }, + noRepo: { enum: noRepo.isolationProperty.protocol.enum, value: noRepo.isolationValue, branch: noRepo.branchProperty, prefix: noRepo.worktreeBranchPrefixProperty, includeFiles: noRepo.worktreeIncludeFilesProperty, branchTrack: noRepo.worktreeBranchTrackProperty, createNewBranch: noRepo.worktreeCreateNewBranchProperty }, + repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly, createNewBranchReadOnly: repoWorktree.worktreeCreateNewBranchProperty?.protocol.readOnly }, repoWorktreeSelected: { branchDefault: repoWorktreeSelected.branchDefault, branchValue: repoWorktreeSelected.branchValue, branchEnum: repoWorktreeSelected.branchProperty?.protocol.enum }, - repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty }, - noCommits: { enum: noCommits.isolationProperty.protocol.enum, value: noCommits.isolationValue, branch: noCommits.branchProperty, prefix: noCommits.worktreeBranchPrefixProperty, includeFiles: noCommits.worktreeIncludeFilesProperty, branchTrack: noCommits.worktreeBranchTrackProperty }, + repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty, hasCreateNewBranch: !!repoFolder.worktreeCreateNewBranchProperty }, + noCommits: { enum: noCommits.isolationProperty.protocol.enum, value: noCommits.isolationValue, branch: noCommits.branchProperty, prefix: noCommits.worktreeBranchPrefixProperty, includeFiles: noCommits.worktreeIncludeFilesProperty, branchTrack: noCommits.worktreeBranchTrackProperty, createNewBranch: noCommits.worktreeCreateNewBranchProperty }, }, { - noRepo: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined }, - repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, branchTrackReadOnly: true }, + noRepo: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined }, + repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, branchTrackReadOnly: true, createNewBranchReadOnly: true }, repoWorktreeSelected: { branchDefault: 'main', branchValue: 'feature', branchEnum: ['main'] }, - repoFolder: { value: 'folder', branchDefault: 'feature', branchReadOnly: true, hasPrefix: true, hasIncludeFiles: true, hasBranchTrack: true }, - noCommits: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined }, + repoFolder: { value: 'folder', branchDefault: 'feature', branchReadOnly: true, hasPrefix: true, hasIncludeFiles: true, hasBranchTrack: true, hasCreateNewBranch: true }, + noCommits: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined }, }); }); @@ -205,6 +205,49 @@ suite('WorktreeIsolation', () => { }); }); + test('checks out an existing selected branch and uses the default branch as the diff base', async () => { + const gitService = createGitService(); + gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' }); + const isolation = createIsolation(disposables, { + gitService, + branchNameGenerator: { generateBranchName: async () => { throw new Error('should not generate a branch'); } }, + }); + + const worktree = await isolation.resolveWorkingDirectory({ + sessionUri, + sessionId, + workingDirectory: repoRoot, + config: { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: 'feature', + [SessionConfigKey.WorktreeBranchTrack]: true, + [SessionConfigKey.WorktreeCreateNewBranch]: false, + }, + }); + + assert.deepStrictEqual({ + worktree: worktree?.toString(), + addWorktreeArgs: addWorktreeCalls.map(call => ({ + commitish: call.commitish, + newBranchName: call.newBranchName, + track: call.track, + preferRemoteBranch: call.preferRemoteBranch, + })), + branchName: await db.getMetadata('copilot.worktree.branchName'), + diffBaseBranch: await db.getMetadata('agentHost.diffBaseBranch'), + }, { + worktree: URI.joinPath(worktreesRoot, 'feature').toString(), + addWorktreeArgs: [{ + commitish: 'feature', + newBranchName: undefined, + track: true, + preferRemoteBranch: false, + }], + branchName: 'feature', + diffBaseBranch: 'origin/main', + }); + }); + test('resolveWorkingDirectory creates a worktree, persists metadata, queues the announcement, and is idempotent', async () => { const isolation = createIsolation(disposables); const config = { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }; diff --git a/src/vs/platform/assignment/common/assignment.ts b/src/vs/platform/assignment/common/assignment.ts index ed12c882e5ce75..07e6fbe3649f30 100644 --- a/src/vs/platform/assignment/common/assignment.ts +++ b/src/vs/platform/assignment/common/assignment.ts @@ -252,7 +252,7 @@ export class VSCodeCoreAssignmentsFilterProvider implements IExperimentationFilt } } -export function getInternalOrg(organisations: string[] | undefined): 'vscode' | 'github' | 'microsoft' | undefined { +export function getInternalOrg(organisations: readonly string[] | undefined): 'vscode' | 'github' | 'microsoft' | undefined { const isVSCodeInternal = organisations?.includes('Visual-Studio-Code'); const isGitHubInternal = organisations?.includes('github'); const isMicrosoftInternal = organisations?.includes('microsoft') || organisations?.includes('ms-copilot') || organisations?.includes('MicrosoftCopilot'); diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 5784b43920bacc..8b0b738a0dbdfc 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -39,14 +39,7 @@ import { ISessionsProvidersService } from '../../services/sessions/browser/sessi import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; import { CLOSE_CHAT_COMMAND_ID } from '../../common/sessionCommands.js'; -import { MenuItemAction } from '../../../platform/actions/common/actions.js'; -import { ChatPillActionViewItem } from '../../../workbench/browser/chatPills.js'; -import { SessionActivatingActionRunner } from '../sessionActionRunner.js'; -import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; import { getSessionConversationStatusAriaLabel } from '../sessionConversationGroups.js'; -import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; -import { observableConfigValue } from '../../../platform/observable/common/platformObservableUtils.js'; -import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../common/sessionConfig.js'; interface IChatTab { readonly chat: IChat; @@ -115,9 +108,6 @@ export class ChatCompositeBar extends Disposable { private readonly _newChatContainer: HTMLElement; private readonly _sessionActionsContainer: HTMLElement; private readonly _sessionToolbar: MenuWorkbenchToolBar; - private readonly _metaRow: HTMLElement; - private readonly _metaToolbar: MenuWorkbenchToolBar; - private readonly _showMetadataInChatInput: IObservable; private readonly _tabs: IChatTab[] = []; private readonly _tabDisposables = this._register(new DisposableStore()); @@ -126,7 +116,6 @@ export class ChatCompositeBar extends Disposable { private _editingTab: IChatTab | undefined; private _delegate: IChatCompositeBarDelegate | undefined; private _showSessionActions = false; - private _metadataInInput = false; private readonly _onDidChangeVisibility = this._register(new Emitter()); readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; @@ -157,12 +146,9 @@ export class ChatCompositeBar extends Disposable { @IInstantiationService private readonly _instantiationService: IInstantiationService, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @ICommandService private readonly _commandService: ICommandService, - @ISessionsService sessionsService: ISessionsService, - @IConfigurationService configurationService: IConfigurationService, ) { super(); - this._showMetadataInChatInput = observableConfigValue(SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING, false, configurationService); this._container = $('.chat-composite-bar.session-chat-tabs-bar'); // Tabs row — only shown when the group has multiple chats or is split out. @@ -202,21 +188,6 @@ export class ChatCompositeBar extends Disposable { highlightToggledItems: true, })); - this._metaRow = $('.chat-composite-bar-meta-row'); - this._container.appendChild(this._metaRow); - const metaToolbarContainer = $('.chat-composite-bar-meta-toolbar'); - this._metaRow.appendChild(metaToolbarContainer); - const metaActionRunner = this._register(new SessionActivatingActionRunner(() => this._delegate?.session, sessionsService)); - this._metaToolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, metaToolbarContainer, Menus.SessionHeaderMeta, { - hiddenItemStrategy: HiddenItemStrategy.Ignore, - menuOptions: { shouldForwardArgs: true }, - actionRunner: metaActionRunner, - actionViewItemProvider: (action, options) => action instanceof MenuItemAction - ? this._instantiationService.createInstance(ChatPillActionViewItem, undefined, action, options) - : undefined, - })); - this._register(this._metaToolbar.onDidChangeMenuItems(() => this._updateMetaRowVisibility())); - const preventMiddleButtonDefault = (e: MouseEvent) => { if (e.button === 1 && !this._isInTabInput(e)) { e.preventDefault(); @@ -269,7 +240,6 @@ export class ChatCompositeBar extends Disposable { this._delegate = delegate; this._sessionToolbar.context = delegate?.session; - this._metaToolbar.context = delegate?.session; const store = new DisposableStore(); this._groupDisposables.value = store; @@ -292,18 +262,12 @@ export class ChatCompositeBar extends Disposable { this._newChatContainer.classList.toggle('hidden', !supportsMultipleChats || isQuickChat); this._newChatAction.enabled = supportsMultipleChats && !isQuickChat && !delegate.session.isArchived.read(reader); this._showSessionActions = delegate.showSessionActions.read(reader); - this._metadataInInput = this._showMetadataInChatInput.read(reader); this._sessionActionsContainer.classList.toggle('hidden', !this._showSessionActions); - this._updateMetaRowVisibility(); this._setVisible(delegate.visible.read(reader)); })); } - private _updateMetaRowVisibility(): void { - this._metaRow.style.display = this._showSessionActions && !this._metadataInInput && !this._metaToolbar.isEmpty() ? '' : 'none'; - } - setAriaLabel(label: string): void { this._tabsContainer.setAttribute('aria-label', label); } diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index f82c736eb8f98e..b3ba42b1693bf8 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -12,7 +12,7 @@ overflow: hidden; } -/* Header host: title row + meta row. */ +/* Header host. */ .chat-composite-bar.session-header-bar { padding: 0 var(--vscode-spacing-size100); box-sizing: border-box; @@ -29,8 +29,7 @@ --chat-tab-max-width: min(200px, 40cqi); } -/* Header: a status icon column next to a main column (title row + meta row). - Mirrors the sessions list so the meta row aligns under the title. */ +/* Header: a status icon column next to the title row. */ .chat-composite-bar-header { display: flex; flex-direction: row; @@ -39,7 +38,7 @@ border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--session-view-foreground, var(--chat-tab-active-foreground)) 12%, transparent); } -/* Main column stacks the title row and the meta row */ +/* Main column hosts the title row. */ .chat-composite-bar-header-main { display: flex; flex-direction: column; @@ -146,25 +145,10 @@ margin-left: auto; } -/* Meta row: workspace + contributed changes / pull request buttons. - No `overflow: hidden` here — it would clip the meta buttons' focus ring at the - row's 22px height. The workspace label self-truncates via its own containers. */ -.chat-composite-bar-meta-row { - display: flex; - align-items: center; - gap: 6px; - height: 22px; - font-size: var(--vscode-agents-fontSize-label1, 12px); - font-weight: var(--vscode-agents-fontWeight-regular, 400); - line-height: 18px; - color: var(--chat-tab-inactive-foreground, var(--session-view-foreground)); - white-space: nowrap; -} - -/* Session header meta toolbar */ -.chat-composite-bar-meta-toolbar, -.chat-composite-bar-meta-toolbar .monaco-action-bar, -.chat-composite-bar-meta-toolbar .actions-container { +/* Metadata action fixture host. */ +.session-metadata-pill-toolbar, +.session-metadata-pill-toolbar .monaco-action-bar, +.session-metadata-pill-toolbar .actions-container { display: inline-flex; align-items: center; height: 100%; @@ -172,8 +156,8 @@ max-width: 100%; } -/* Spacing between the contributed meta buttons (e.g. changes · pull request). */ -.chat-composite-bar-meta-toolbar .actions-container { +/* Spacing between contributed metadata buttons. */ +.session-metadata-pill-toolbar .actions-container { gap: 6px; } diff --git a/src/vs/sessions/browser/parts/sessionHeader.ts b/src/vs/sessions/browser/parts/sessionHeader.ts index fcde5fa5b2272e..e47f5fd7a91cec 100644 --- a/src/vs/sessions/browser/parts/sessionHeader.ts +++ b/src/vs/sessions/browser/parts/sessionHeader.ts @@ -10,15 +10,13 @@ import { $, addDisposableGenericMouseDownListener, addDisposableListener, addSta import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; -import { autorun, IObservable, IReader, observableSignalFromEvent } from '../../../base/common/observable.js'; +import { autorun, IReader } from '../../../base/common/observable.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { localize } from '../../../nls.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; -import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; import { getUntitledSessionTitle } from '../../services/sessions/common/session.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; -import { MenuItemAction } from '../../../platform/actions/common/actions.js'; import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; import { Menus } from '../menus.js'; import { LocalSelectionTransfer } from '../../../platform/dnd/browser/dnd.js'; @@ -28,16 +26,10 @@ import { applySessionBarThemeColors } from './sessionBarStyles.js'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { SessionStatusIcon } from '../sessionStatusIcon.js'; -import { ChatPillActionViewItem } from '../../../workbench/browser/chatPills.js'; -import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; -import { observableConfigValue } from '../../../platform/observable/common/platformObservableUtils.js'; -import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../common/sessionConfig.js'; -import { SessionActivatingActionRunner } from '../sessionActionRunner.js'; /** * The session header shown at the top of a session view. It surfaces the session - * identity, optional workspace metadata, contributed metadata pills, and the - * session toolbars. + * identity and session toolbar. * * It is intentionally decoupled from the {@link ChatCompositeBar} (the chat tab * strip) so the two surfaces evolve independently. The hosting view tells the @@ -49,9 +41,7 @@ export class SessionHeader extends Disposable { private readonly _iconEl: HTMLElement; private readonly _titleEl: HTMLElement; private readonly _titleTextEl: HTMLElement; - private readonly _metaRow: HTMLElement; private readonly _toolbar: MenuWorkbenchToolBar; - private readonly _metaToolbar: MenuWorkbenchToolBar; private readonly _titleActionsEl: HTMLElement; private readonly _sessionDisposables = this._register(new MutableDisposable()); @@ -75,9 +65,6 @@ export class SessionHeader extends Disposable { private readonly _sessionTransfer = LocalSelectionTransfer.getInstance(); - private readonly _metaActionsSignal: IObservable; - private readonly _showMetadataInChatInput: IObservable; - private readonly _statusIcon: SessionStatusIcon; get element(): HTMLElement { @@ -98,18 +85,12 @@ export class SessionHeader extends Disposable { @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, - @ISessionsService private readonly _sessionsService: ISessionsService, - @IConfigurationService configurationService: IConfigurationService, ) { super(); - this._showMetadataInChatInput = observableConfigValue(SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING, false, configurationService); this._container = $('.chat-composite-bar.session-header-bar'); - // Header: a status icon column alongside a main column that stacks the title - // row (title + actions) and the meta row (workspace · diff). This mirrors the - // sessions list so the meta row aligns under the title rather than under the - // status icon. + // Header: a status icon column alongside the title and actions. const header = $('.chat-composite-bar-header'); this._container.appendChild(header); @@ -150,41 +131,7 @@ export class SessionHeader extends Disposable { highlightToggledItems: true, })); - this._metaRow = $('.chat-composite-bar-meta-row'); - main.appendChild(this._metaRow); - - // Session header meta toolbar. Actions are contributed into the generic - // Menus.SessionHeaderMeta menu: the files view contributes the workspace - // folder pill (opens the Files view), the changes view contributes the - // diff-stats action (opens the multi-file diff editor) and the GitHub - // contribution contributes the pull request pill (opens the PR on GitHub), - // each rendered as a compact secondary button pill via - // ChatPillActionViewItem. - const metaToolbarContainer = $('.chat-composite-bar-meta-toolbar'); - this._metaRow.appendChild(metaToolbarContainer); - // Commands contributed into the header meta toolbar (e.g. View All Changes) - // operate on this view's session. Promote it to the active session before - // running any of them via a custom action runner, so the command always - // targets the clicked session even when another session is active. - const metaActionRunner = this._register(new SessionActivatingActionRunner(() => this._session, this._sessionsService)); - this._metaToolbar = this._register(instantiationService.createInstance(MenuWorkbenchToolBar, metaToolbarContainer, Menus.SessionHeaderMeta, { - hiddenItemStrategy: HiddenItemStrategy.Ignore, - menuOptions: { shouldForwardArgs: true }, - actionRunner: metaActionRunner, - // Render every meta action as a consistent `icon title` pill unless it - // registers its own action view item via IActionViewItemService. - actionViewItemProvider: (action, options) => { - if (action instanceof MenuItemAction) { - return instantiationService.createInstance(ChatPillActionViewItem, undefined, action, options); - } - return undefined; - }, - })); - // The meta row separator/visibility tracks whether the meta toolbar has any - // contributed actions, so recompute the header whenever they change. - this._metaActionsSignal = observableSignalFromEvent(this, this._metaToolbar.onDidChangeMenuItems); - - // Report height changes (e.g. meta row content wrapping) so the host can re-layout + // Report height changes so the host can re-layout. const heightObserver = this._register(new DisposableResizeObserver('SessionHeader.height', () => { this._onDidChangeHeight.fire(); })); @@ -235,9 +182,9 @@ export class SessionHeader extends Disposable { return; } - // Don't swallow a click on the toolbar or meta row pills into a session drag. + // Don't swallow a click on the toolbar into a session drag. const target = this._lastPointerDownTarget; - if (target && (this._titleActionsEl.contains(target) || this._metaRow.contains(target))) { + if (target && this._titleActionsEl.contains(target)) { e.preventDefault(); return; } @@ -276,7 +223,6 @@ export class SessionHeader extends Disposable { this._cancelTitleEditing(); this._session = session; this._toolbar.context = session; - this._metaToolbar.context = session; this._statusIcon.reset(); const store = new DisposableStore(); @@ -313,8 +259,8 @@ export class SessionHeader extends Disposable { private _updateHeader(session: IActiveSession, reader: IReader): void { // Session icon — the SessionStatusIcon widget owns the rendering (spinner vs. // codicon, cross-fade, reduced-motion); here we just feed it the latest state. - // The pull request is surfaced in the meta row, so in terminal/default states the - // title shows the read/unread dot indicator (no session type or PR icon). + // Metadata is surfaced above the chat input, so the title keeps the + // read/unread status indicator. const status = session.status.read(reader); const isRead = session.isRead.read(reader); const isArchived = session.isArchived.read(reader); @@ -324,14 +270,6 @@ export class SessionHeader extends Disposable { const isQuickChat = session.isQuickChat?.read(reader) ?? false; this._titleTextEl.textContent = session.title.read(reader) || getUntitledSessionTitle(isQuickChat); this._titleEl.classList.toggle('editable', this._isTitleEditable()); - const showMetadataInChatInput = this._showMetadataInChatInput.read(reader); - - // Meta row: contributed action pills (workspace folder · diff stats · pull request). - // Reading the signal re-runs this on menu changes. - this._metaActionsSignal.read(reader); - const hasMetaActions = !this._metaToolbar.isEmpty(); - - this._metaRow.style.display = !showMetadataInChatInput && hasMetaActions ? '' : 'none'; this._onDidChangeHeight.fire(); } diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 39a4f94ca56980..77bbb0b6299858 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -18,15 +18,13 @@ import { AbstractChatView, IChatViewOptions } from './chatView.js'; import { ChatGroupsView } from './chatGroupsView.js'; import { SessionHeader, SessionViewFloatingToolbar } from './sessionHeader.js'; import { ISessionContext, SessionContext } from '../../services/sessions/browser/sessionContext.js'; -import { autorun, IObservable, observableValue } from '../../../base/common/observable.js'; +import { autorun, observableValue } from '../../../base/common/observable.js'; import { SessionIsMaximizedContext } from '../../common/contextkeys.js'; import { AGENTS_CENTERED_CONTENT_MAX_WIDTH } from '../../common/layoutConstants.js'; import { setActiveSessionContextKeys } from '../../services/sessions/common/sessionContextKeys.js'; +import { ISessionChangesStatsCache } from '../../services/sessions/common/sessionChangesStatsCache.js'; import { applySessionViewThemeColors } from './sessionBarStyles.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; -import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; -import { observableConfigValue } from '../../../platform/observable/common/platformObservableUtils.js'; -import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../common/sessionConfig.js'; /** * Options passed to {@link SessionView.openSession}. Extends the chat view @@ -86,18 +84,16 @@ export class SessionView extends Disposable implements ISerializableView { private _isLeafVisible = true; private readonly _sessionObs = observableValue(this, undefined); - private readonly _showMetadataInChatInput: IObservable; constructor( @IChatViewFactory private readonly _chatViewFactory: IChatViewFactory, @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, @IThemeService private readonly themeService: IThemeService, - @IConfigurationService configurationService: IConfigurationService, + @ISessionChangesStatsCache private readonly _changesStatsCache: ISessionChangesStatsCache, ) { super(); - this._showMetadataInChatInput = observableConfigValue(SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING, false, configurationService); // Scoped context key service so toolbars hosted within can react to // session-specific context keys (e.g. sessionIsCreated, sessionIsSticky). const scopedContextKeyService = this._scopedContextKeyService = this._register(contextKeyService.createScoped(this.element)); @@ -157,8 +153,7 @@ export class SessionView extends Disposable implements ISerializableView { this._register(autorun(reader => { const session = this._sessionObs.read(reader); - const tabsReplaceHeader = this._showMetadataInChatInput.read(reader) - && this._groupsView.groupCount.read(reader) === 1 + const tabsReplaceHeader = this._groupsView.groupCount.read(reader) === 1 && (session?.isCreated.read(reader) ?? false) && (session?.shouldShowChatTabs.read(reader) ?? false); this._header.setVisible(!tabsReplaceHeader); @@ -200,7 +195,7 @@ export class SessionView extends Disposable implements ISerializableView { // scoped service whenever the session's observable properties change. // Passing `undefined` resets the keys to their defaults. return autorun(reader => { - setActiveSessionContextKeys(session, this._scopedContextKeyService, reader); + setActiveSessionContextKeys(session, this._scopedContextKeyService, reader, this._changesStatsCache); }); } diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index eb15ba98562ae5..e5514cc31837e5 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -40,6 +40,7 @@ export const SessionIsReadContext = new RawContextKey('sessionIsRead', export const SessionIsArchivedContext = new RawContextKey('sessionIsArchived', false, localize('sessionIsArchived', "Whether the session in scope is archived/marked as done (the active session globally, or a specific session within an isolated component such as the session view or a context menu overlay)")); export const SessionIsActiveContext = new RawContextKey('sessionIsActive', false, localize('sessionIsActive', "Whether the session in scope is in progress or needs input")); export const SessionHasChangesContext = new RawContextKey('sessionHasChanges', false, localize('sessionHasChanges', "Whether the session view's session has pending changes (insertions or deletions)")); +export const SessionHasCachedChangesContext = new RawContextKey('sessionHasCachedChanges', false, localize('sessionHasCachedChanges', "Whether the session view's session has remembered changes from the last time its changes pill was shown, while it has not reported its own changes yet. Used to render the changes pill optimistically when a session opens")); export const SessionHasPullRequestContext = new RawContextKey('sessionHasPullRequest', false, localize('sessionHasPullRequest', "Whether the session view's session is associated with a GitHub pull request")); export const SessionHasIssuesContext = new RawContextKey('sessionHasIssues', false, localize('sessionHasIssues', "Whether the session view's session references at least one GitHub issue")); export const SessionHasWorkspaceContext = new RawContextKey('sessionHasWorkspace', false, localize('sessionHasWorkspace', "Whether the session view's session has an associated workspace folder")); diff --git a/src/vs/sessions/common/sessionConfig.ts b/src/vs/sessions/common/sessionConfig.ts index 09d79fe367365c..deda8edc5edf24 100644 --- a/src/vs/sessions/common/sessionConfig.ts +++ b/src/vs/sessions/common/sessionConfig.ts @@ -13,8 +13,6 @@ import type { ResolveSessionConfigResult } from '../../platform/agentHost/common */ export const DOCK_DETAIL_PANEL_SETTING = 'sessions.layout.singlePaneDetailPanel'; -export const SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING = 'chat.agentSessions.showSessionMetadataInInput'; - export function isSessionConfigComplete(config: ResolveSessionConfigResult): boolean { return (config.schema.required ?? []).every(property => config.values[property] !== undefined); } diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index c42c3fc1b3bd58..af4995d32cf994 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -27,10 +27,11 @@ import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/d import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; -import { IsQuickChatSessionContext, SessionHasChangesContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; +import { IsQuickChatSessionContext, SessionHasCachedChangesContext, SessionHasChangesContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { SessionChangesetOperationScope } from '../../../services/sessions/common/session.js'; +import { ISessionChangesStatsCache, readSessionChangesStats } from '../../../services/sessions/common/sessionChangesStatsCache.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { IChangesViewService } from '../common/changesViewService.js'; import { ChangesMultiDiffSourceResolver, SessionChangesReviewedFilesContext } from './changesMultiDiffSourceResolver.js'; @@ -49,15 +50,14 @@ class ViewAllChangesAction extends Action2 { title: localize2('agentSessions.changes', 'Changes'), icon: Codicon.diffMultiple, f1: false, - // Diff stats shown in the session header meta row - // (vs/sessions/browser/parts/sessionHeader.ts). Rendered with a - // custom action view item that shows the live +/- counts. + // Metadata pill rendered with live +/- counts, or the counts last shown + // for the session while it has not reported its changes yet. menu: { id: Menus.SessionHeaderMeta, group: 'navigation', order: 0, when: ContextKeyExpr.and( - SessionHasChangesContext, + ContextKeyExpr.or(SessionHasChangesContext, SessionHasCachedChangesContext), ContextKeyExpr.or(IsQuickChatSessionContext.negate(), SinglePaneLayoutEnabledContext) ) }, @@ -233,10 +233,8 @@ interface IDiffStats { } /** - * Renders the {@link ViewAllChangesAction} menu item contributed into {@link Menus.SessionHeaderMeta} - * (the session header meta row) as a ` files +insertions -deletions` pill. It extends the - * generic {@link ChatPillActionViewItem} (so the icon and label render consistently with other - * meta actions) and appends the session's live aggregate diff stats. Activating the item runs the + * Renders the {@link ViewAllChangesAction} as a ` files +insertions -deletions` + * metadata pill. It appends the session's live aggregate diff stats. Activating the item runs the * action, which opens the multi-file diff editor. * * The stats are read from the {@link ISessionContext} so the correct per-session changes @@ -244,6 +242,10 @@ interface IDiffStats { * session's {@link ISession.changesSummary} when available, falling back to aggregating the * changeset the provider marks as {@link ISessionChangeset.isDefault} (or the session's * top-level {@link IActiveSession.changes} when none is default). + * + * A session reports its changes late, so until it reports any the counts last shown for it + * are taken from the {@link ISessionChangesStatsCache} — the pill is then already there, + * with plausible counts, the moment the session opens. */ export class ViewAllChangesActionViewItem extends ChatPillActionViewItem { @@ -253,6 +255,7 @@ export class ViewAllChangesActionViewItem extends ChatPillActionViewItem { action: MenuItemAction, options: IActionViewItemOptions, @ISessionContext sessionContext: ISessionContext, + @ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache, ) { super(undefined, action, options); @@ -261,33 +264,15 @@ export class ViewAllChangesActionViewItem extends ChatPillActionViewItem { const workspace = session?.workspace.read(reader); const branch = workspace?.folders[0]?.gitRepository?.branchName?.trim(); - // Prefer the provider-supplied changes summary which reflects the - // session's authoritative aggregate. Fall back to aggregating the - // default changeset's changes when no summary is available. - const changesSummary = session?.changesSummary?.read(reader); - if (changesSummary) { - return { - branch, - files: changesSummary.files, - insertions: changesSummary.additions, - deletions: changesSummary.deletions, - } satisfies IDiffStats; - } - - const defaultChangeset = session?.changesets.read(reader)?.find(c => c.isDefault.read(reader)); - const changes = (defaultChangeset?.changes.read(reader) ?? session?.changes.read(reader)) ?? []; - - let insertions = 0, deletions = 0; - for (const change of changes) { - insertions += change.insertions; - deletions += change.deletions; - } + const stats = session + ? readSessionChangesStats(session, reader) ?? changesStatsCache.get(session.sessionId, reader) + : undefined; return { branch, - files: changes.length, - insertions, - deletions, + files: stats?.files ?? 0, + insertions: stats?.insertions ?? 0, + deletions: stats?.deletions ?? 0, } satisfies IDiffStats; }); @@ -332,9 +317,7 @@ export class ViewAllChangesActionViewItem extends ChatPillActionViewItem { } /** - * Registers the {@link ViewAllChangesActionViewItem} for the diff-stats action in the - * session header meta toolbar. Registering it here (rather than in the core session header) - * keeps the rendering of the changes-owned action co-located with the action itself. + * Registers the {@link ViewAllChangesActionViewItem} for the diff-stats metadata pill. */ class ViewAllChangesActionViewItemContribution extends Disposable implements IWorkbenchContribution { @@ -345,11 +328,7 @@ class ViewAllChangesActionViewItemContribution extends Disposable implements IWo ) { super(); - // The action view item service only notifies toolbars of a factory via - // the event passed to register(), not on registration itself. A session - // header restored with existing changes may create its meta toolbar - // before this contribution runs, so announce the factory once right - // after registering to make those toolbars re-render and pick it up. + // Announce the factory after registration so existing metadata pills re-render. const onDidRegister = this._register(new Emitter()); this._register(actionViewItemService.register(Menus.SessionHeaderMeta, ViewAllChangesAction.ID, (action, options, instantiationService) => { if (!(action instanceof MenuItemAction)) { @@ -361,6 +340,39 @@ class ViewAllChangesActionViewItemContribution extends Disposable implements IWo } } +/** + * Remembers the changes pill shown for each visible session so it can be rendered + * optimistically the next time that session is opened, before the provider has + * reported its changes. Recording sessions as they are shown (rather than from the + * pill itself) also keeps the cache honest: a session that ends up without changes + * drops its entry instead of keeping a stale pill. + */ +class SessionChangesStatsCacheContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.changesStatsCache'; + + constructor( + @ISessionsService sessionsService: ISessionsService, + @ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache, + ) { + super(); + + this._register(autorun(reader => { + for (const session of sessionsService.visibleSessions.read(reader)) { + // While the worktree is pending the reported changes belong to the + // checkout the session was started from, not to the session. + if (!session || session.worktreePending?.read(reader)) { + continue; + } + const stats = readSessionChangesStats(session, reader); + if (stats) { + changesStatsCache.set(session.sessionId, stats); + } + } + })); + } +} + // --- Multi-diff source resolver /** @@ -479,3 +491,4 @@ class ChangesetOperationsActionControllerContribution extends Disposable impleme registerWorkbenchContribution2(ChangesMultiDiffSourceResolverContribution.ID, ChangesMultiDiffSourceResolverContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChangesetOperationsActionControllerContribution.ID, ChangesetOperationsActionControllerContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ViewAllChangesActionViewItemContribution.ID, ViewAllChangesActionViewItemContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(SessionChangesStatsCacheContribution.ID, SessionChangesStatsCacheContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 548b4b9b36ac50..a5745eedae41a7 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -9,7 +9,6 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import product from '../../../../platform/product/common/product.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionsManagementService, inheritableSessionTarget } from '../../../services/sessions/common/sessionsManagement.js'; @@ -49,7 +48,6 @@ import { ISessionsChatViewStateService, SessionsChatViewStateService } from './c import { SessionsChatResponseFileChangesService } from './sessionTurnChanges.js'; import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; import { SessionsChatPetAchievementContribution } from './chatPetAchievements.js'; -import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; class NewChatInSessionsWindowAction extends Action2 { @@ -153,11 +151,5 @@ Registry.as(ConfigurationExtensions.Configuration).regis scope: ConfigurationScope.APPLICATION, description: localize('chat.agentSessions.scopedInputHistory', "Controls whether chat input history in the Agents Window is scoped to the current session. Disable this to use shared input history across sessions."), }, - [SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING]: { - type: 'boolean', - default: product.quality !== 'stable', - scope: ConfigurationScope.APPLICATION, - description: localize('chat.agentSessions.showSessionMetadataInInput', "Controls whether session metadata such as changes, pull requests, and issues appears above the chat input instead of in the session header."), - }, }, }); diff --git a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts index 4e9d089a8f50dc..4786ffd10fbfd7 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts @@ -6,20 +6,28 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { getMediaMime } from '../../../../base/common/mime.js'; import { derived, IObservable, IReader } from '../../../../base/common/observable.js'; import { basename, getComparisonKey } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { toAction } from '../../../../base/common/actions.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import type { IChatPillEntry, IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { openChatTurnFile, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; +import type { IImageCarouselCollection } from '../../../../workbench/contrib/imageCarousel/browser/imageCarouselTypes.js'; import { SessionArtifactKind, SessionFileOperation, type ISessionArtifact, type ISessionFile } from '../../../services/sessions/common/session.js'; import type { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +const OPEN_IMAGE_CAROUSEL_COMMAND_ID = 'workbench.action.chat.openImageInCarousel'; + const artifactIcons: ReadonlyMap = new Map([ [SessionArtifactKind.PullRequest, Codicon.gitPullRequest], [SessionArtifactKind.Issue, Codicon.issues], @@ -42,9 +50,15 @@ const sectionOrder: readonly { readonly kind: SessionArtifactKind; readonly titl export interface ISessionArtifactActions { openExternal(link: URI): void; openResource(uri: URI): void; + openImages(images: readonly ISessionArtifactImage[], startIndex: number): void; copy(text: string): void; } +export interface ISessionArtifactImage { + readonly uri: URI; + readonly mimeType: string; +} + function artifactValueKey(artifact: ISessionArtifact): string { if (artifact.uri) { return getComparisonKey(artifact.uri); @@ -52,7 +66,12 @@ function artifactValueKey(artifact: ISessionArtifact): string { return (artifact.link?.toString() ?? artifact.commitHash ?? artifact.id).toLowerCase(); } -function artifactLocation(uri: URI, label: string): Pick { +/** + * The location details shown for an artifact: its URI/link as the hover beside + * the dropdown row, the plain-text screen reader description, and the tooltip, + * while the accessible name stays the action the entry performs. + */ +export function sessionArtifactLocation(uri: URI, label: string): Pick { const value = uri.toString(true); return { ariaDescription: value, @@ -62,6 +81,11 @@ function artifactLocation(uri: URI, label: string): Pick actions.openResource(uri) }; + return { id: artifact.id, label, resource: uri, ...sessionArtifactLocation(uri, label), open: () => actions.openResource(uri) }; } const icon = artifactIcons.get(artifact.kind) ?? Codicon.archive; @@ -86,7 +110,7 @@ function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions): run: () => actions.copy(artifact.commitHash!), })] : []; - return { id: artifact.id, label: artifact.label, icon, toolbarActions: copyAction, ...artifactLocation(link, artifact.label), open: () => actions.openExternal(link) }; + return { id: artifact.id, label: artifact.label, icon, toolbarActions: copyAction, ...sessionArtifactLocation(link, artifact.label), open: () => actions.openExternal(link) }; } if (artifact.kind === SessionArtifactKind.Resource) { @@ -94,14 +118,14 @@ function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions): return undefined; } const uri = artifact.uri; - return { id: artifact.id, label: artifact.label, icon, ...artifactLocation(uri, artifact.label), open: () => actions.openResource(uri) }; + return { id: artifact.id, label: artifact.label, icon, ...sessionArtifactLocation(uri, artifact.label), open: () => actions.openResource(uri) }; } if (!artifact.link) { return undefined; } const link = artifact.link; - return { id: artifact.id, label: artifact.label, icon, ...artifactLocation(link, artifact.label), open: () => actions.openExternal(link) }; + return { id: artifact.id, label: artifact.label, icon, ...sessionArtifactLocation(link, artifact.label), open: () => actions.openExternal(link) }; } /** @@ -109,11 +133,20 @@ function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions): * the previewable files the session wrote outside its workspace, de-duplicated * with the agent's own entries winning. */ -export function buildSessionArtifactSections(artifacts: readonly ISessionArtifact[], externalFiles: readonly ISessionFile[], actions: ISessionArtifactActions): readonly IChatPillSection[] { +export function buildSessionArtifactSections(artifacts: readonly ISessionArtifact[], externalFiles: readonly ISessionFile[], actions: ISessionArtifactActions, imageCarouselEnabled: boolean): readonly IChatPillSection[] { const entriesByKind = new Map(); + const images: ISessionArtifactImage[] = []; const seen = new Set(); for (const artifact of artifacts) { + const imageMimeType = artifact.uri ? getImageMimeType(artifact.uri) : undefined; + if (artifact.kind === SessionArtifactKind.File && artifact.uri && imageMimeType) { + if (!seen.has(artifactValueKey(artifact))) { + seen.add(artifactValueKey(artifact)); + images.push({ uri: artifact.uri, mimeType: imageMimeType }); + } + continue; + } const entry = toEntry(artifact, actions); if (!entry || seen.has(artifactValueKey(artifact))) { continue; @@ -125,18 +158,43 @@ export function buildSessionArtifactSections(artifacts: readonly ISessionArtifac } for (const file of externalFiles) { - if (file.operation === SessionFileOperation.Deleted || !previewKind(file.uri) || seen.has(getComparisonKey(file.uri))) { + const imageMimeType = getImageMimeType(file.uri); + if (file.operation === SessionFileOperation.Deleted || (!previewKind(file.uri) && !imageMimeType) || seen.has(getComparisonKey(file.uri))) { continue; } seen.add(getComparisonKey(file.uri)); + if (imageMimeType) { + images.push({ uri: file.uri, mimeType: imageMimeType }); + continue; + } const entries = entriesByKind.get(SessionArtifactKind.File) ?? []; const label = basename(file.uri); - entries.push({ id: file.uri.toString(), label, resource: file.uri, ...artifactLocation(file.uri, label), open: () => actions.openResource(file.uri) }); + entries.push({ id: file.uri.toString(), label, resource: file.uri, ...sessionArtifactLocation(file.uri, label), open: () => actions.openResource(file.uri) }); entriesByKind.set(SessionArtifactKind.File, entries); } const sections: IChatPillSection[] = []; for (const { kind, title } of sectionOrder) { + if (kind === SessionArtifactKind.File && images.length) { + sections.push({ + title: localize('sessionArtifacts.images', "Images"), + entries: images.map(({ uri }, index) => { + const label = basename(uri); + return { + id: uri.toString(), + label, + resource: uri, + ...artifactLocation(uri, label), + ...(imageCarouselEnabled + ? { + ariaLabel: localize('sessionArtifacts.openImage', "Open {0} in Images Preview", label), + open: () => actions.openImages(images, index), + } + : { open: () => actions.openResource(uri) }), + }; + }), + }); + } const entries = entriesByKind.get(kind); if (entries?.length) { sections.push({ title, entries }); @@ -153,11 +211,14 @@ export class SessionArtifacts extends Disposable { constructor( session: IObservable, @IClipboardService private readonly _clipboardService: IClipboardService, + @ICommandService private readonly _commandService: ICommandService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IOpenerService private readonly _openerService: IOpenerService, ) { super(); + const imageCarouselEnabled = observableConfigValue(ChatConfiguration.ImageCarouselEnabled, true, this._configurationService); + this.sections = derived(this, reader => { const current = session.read(reader); if (!current) { @@ -167,6 +228,7 @@ export class SessionArtifacts extends Disposable { current.artifacts?.read(reader) ?? [], this._readExternalFiles(current, reader), this._actions(), + imageCarouselEnabled.read(reader), ); }); } @@ -185,6 +247,22 @@ export class SessionArtifacts extends Disposable { } void this._openerService.open(uri, { fromUserGesture: true }); }, + openImages: (images, startIndex) => { + const collection: IImageCarouselCollection = { + id: generateUuid(), + title: localize('sessionArtifacts.imageCarouselTitle', "Artifact Images"), + sections: [{ + title: localize('sessionArtifacts.images', "Images"), + images: images.map(image => ({ + id: image.uri.toString(), + name: basename(image.uri), + mimeType: image.mimeType, + uri: image.uri, + })), + }], + }; + void this._commandService.executeCommand(OPEN_IMAGE_CAROUSEL_COMMAND_ID, { collection, startIndex }); + }, copy: text => { void this._clipboardService.writeText(text); }, }; } diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index e15b8d272fe245..dce749fb0934c6 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -7,7 +7,6 @@ import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindo import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { toAction, Action, Separator, type IAction } from '../../../../base/common/actions.js'; -import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; @@ -18,24 +17,21 @@ import { IContextMenuService } from '../../../../platform/contextview/browser/co import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID, ChatTurnPillsProvider, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, observeTurnStatusPillsEnabled } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; -import { SessionArtifacts } from './sessionArtifacts.js'; +import { SessionArtifacts, sessionArtifactLocation } from './sessionArtifacts.js'; import { chatCustomizationPillOptions, SessionCustomizations, SESSION_CUSTOMIZATIONS_PILL_ID } from './sessionCustomizations.js'; import { localize } from '../../../../nls.js'; import { getChatPillEntries, ChatPillsWidget, IChatPill, IChatPillsModel, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { createChatSectionPill, type IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js'; -import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../changes/common/changes.js'; import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; import { getSessionChatPillMenu, SessionChatPillKind, SessionChatPillVisibility, type ISessionChatPillMenuEntry } from '../common/sessionChatPills.js'; -import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { IChat, isActiveSessionStatus } from '../../../services/sessions/common/session.js'; +import { IChat } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { SessionBackgroundActivitiesControl, sessionSubagentsPillOptions } from './sessionBackgroundActivitiesControl.js'; import { SessionBrowsersControl, sessionBrowsersPillOptions } from './sessionBrowsersControl.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; -import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { SessionMetadataPills } from './sessionMetadataPills.js'; import { SessionActivatingActionRunner } from '../../../browser/sessionActionRunner.js'; import './media/sessionChatInputToolbar.css'; @@ -53,26 +49,11 @@ function computeTurnStats(chat: IChat, reader: IReader): IDiffStats { } return { files, insertions, deletions }; } -/** Whether last-turn pills should remain available for the current session state. */ -export function shouldShowSessionTurnPills(hasDebugData: boolean, turnActive: boolean, showSessionMetadataInInput: boolean, turnStatusPillsEnabled: boolean): boolean { - return hasDebugData || turnStatusPillsEnabled && (turnActive || showSessionMetadataInInput); -} - /** Fake artifacts for the pill debug overlay. */ function buildDebugArtifactSections(debugData: ISessionChatPillsDebugData): readonly IChatPillSection[] { const entries = debugData.markdownFiles.map(name => { const resource = URI.from({ scheme: 'session-chat-pills-debug', path: `/${name}` }); - const location = resource.toString(true); - return { - id: name, - label: name, - resource, - ariaDescription: location, - ariaLabel: localize('sessionArtifacts.open', "Open {0}", name), - hover: { content: new MarkdownString().appendText(location) }, - tooltip: location, - open: () => { }, - }; + return { id: name, label: name, resource, ...sessionArtifactLocation(resource, name), open: () => { } }; }); return entries.length ? [{ title: localize('sessionArtifacts.files', "Files"), entries }] : []; } @@ -139,16 +120,6 @@ export class SessionChatInputToolbar extends Disposable { /** Customization sections shown in the customizations pill. */ private readonly _customizationSections: IObservable; - /** Whether pills may show at all: an agent host session with an active turn. */ - private readonly _active = derived(reader => { - const session = this._session.read(reader); - const chat = this._chat.read(reader); - if (!session || !chat || !isAgentHostProviderId(session.providerId)) { - return false; - } - return isActiveSessionStatus(chat.status.read(reader)); - }); - constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @@ -186,33 +157,23 @@ export class SessionChatInputToolbar extends Disposable { this._customizationSections = sessionCustomizations.sections; const turnStatusPillsEnabled = observeTurnStatusPillsEnabled(this._configurationService); - const showMetadataInChatInput = observableConfigValue(SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING, false, this._configurationService); - const showTurnPills = derived(reader => shouldShowSessionTurnPills( - this._debugData.read(reader) !== undefined, - this._active.read(reader), - showMetadataInChatInput.read(reader), - turnStatusPillsEnabled.read(reader), - )); + const pillsEnabled = derived(reader => this._debugData.read(reader) !== undefined || turnStatusPillsEnabled.read(reader)); const model: IChatTurnPillsModel = { stats: this._diffStats, artifacts: this._artifactSections, - changesEnabled: showTurnPills, - // Artifacts outlive the turn that produced them, so they only need the pills enabled. - artifactsEnabled: derived(reader => this._debugData.read(reader) !== undefined || turnStatusPillsEnabled.read(reader)), + changesEnabled: pillsEnabled, + artifactsEnabled: pillsEnabled, openChanges: () => this._debugData.get() ? undefined : this._openChanges(), }; const turnPills = this._register(instantiationService.createInstance(ChatTurnPillsProvider, model)); - const metadataPills = this._register(instantiationService.createInstance(SessionMetadataPills, this.element, this._session, showMetadataInChatInput)); + const metadataPills = this._register(instantiationService.createInstance(SessionMetadataPills, this.element, this._session)); const visibility = this._register(instantiationService.createInstance(SessionChatPillVisibility)); // Every pill the session currently has data for, before the user's // per-kind visibility choices are applied. const candidatePills = derived(reader => { const turn = turnPills.pills.read(reader); - if (!showMetadataInChatInput.read(reader)) { - return turn; - } return [ ...metadataPills.pills.read(reader), ...turn.filter(pill => pill.action.id !== CHAT_TURN_CHANGES_PILL_ID), @@ -268,7 +229,7 @@ export class SessionChatInputToolbar extends Disposable { pills.element.classList.add('show-file-icons'); this._content.appendChild(pills.element); - // Kinds the session reports data for; the others cannot be toggled. + // Kinds the session reports data for; the others are listed in a separate group. const kindsWithData = derived(reader => { const kinds = new Set(); for (const pill of candidatePills.read(reader)) { @@ -309,7 +270,6 @@ export class SessionChatInputToolbar extends Disposable { id: `sessions.chatPills.toggle.${entry.kind}`, label: entry.label, checked: entry.checked, - enabled: entry.enabled, run: () => visibility.toggle(entry.kind), }); diff --git a/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts b/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts index ed3b9428d53fad..a86d658fcce767 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts @@ -18,6 +18,7 @@ import { Menus } from '../../../browser/menus.js'; import { ISessionContext, SessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { setSessionContextKeys } from '../../../services/sessions/common/sessionContextKeys.js'; +import { ISessionChangesStatsCache } from '../../../services/sessions/common/sessionChangesStatsCache.js'; /** Adapts the session metadata menu to observable chat-pill descriptors. */ export class SessionMetadataPills extends Disposable { @@ -29,11 +30,11 @@ export class SessionMetadataPills extends Disposable { constructor( container: HTMLElement, session: IObservable, - enabled: IObservable, @IActionViewItemService private readonly _actionViewItemService: IActionViewItemService, @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, @IMenuService menuService: IMenuService, + @ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache, ) { super(); @@ -44,7 +45,7 @@ export class SessionMetadataPills extends Disposable { ))); this._register(autorun(reader => { - setSessionContextKeys(session.read(reader), scopedContextKeyService, reader); + setSessionContextKeys(session.read(reader), scopedContextKeyService, reader, changesStatsCache); })); const menu = this._register(menuService.createMenu(Menus.SessionHeaderMeta, scopedContextKeyService, { emitEventsForSubmenuChanges: true })); @@ -54,10 +55,6 @@ export class SessionMetadataPills extends Disposable { )); this.pills = derived(this, reader => { menuSignal.read(reader); - if (!enabled.read(reader)) { - return []; - } - return menu.getActions({ shouldForwardArgs: true }).flatMap(([group, actions]) => { if (group !== 'navigation') { return []; diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 2094641cd9e7fd..5f45f7fd63f9fc 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -42,7 +42,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session.")); content.push(localize('sessionsChat.pullRequestSession', "In a repository section where New Session is a split button, focus New Session and press Right Arrow to reach its dropdown, then activate New Session from Pull Request to open a searchable pull request picker. Pull requests are grouped by review and assignment status. Use the arrow keys to navigate, Enter to create the session, and Escape to close the picker.")); - content.push(localize('sessionsChat.githubReferences', "Pull request and issue pills in the session header open their GitHub item in the GitHub Pull Requests extension when it is available. Pills that represent several items open a keyboard-accessible picker.")); + content.push(localize('sessionsChat.githubReferences', "Pull request and issue pills above the chat input open their GitHub item in the GitHub Pull Requests extension when it is available. Pills that represent several items open a keyboard-accessible picker.")); content.push(localize('sessionsChat.failingChecksPullRequest', "When the active session has failing checks, use Reveal in the banner above the input to open its pull request, or use Fix Checks to ask the agent to address the failures.")); content.push(localize('sessionsChat.pickFolderQuickPick', "To choose a folder from a searchable list instead, use the New Session in Folder command{0}.", '')); content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '')); @@ -56,8 +56,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.micContextMenu', "To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu (for example Shift+F10).")); content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter; the reference appears as a pill above the input that you can remove.")); content.push(localize('sessionsChat.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference.")); - content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach status pills above it, then press Enter or Space to activate a pill. Live browsers appear in their own pill, and background activities such as running subagents in another. A pill with more than one entry opens a picker; use the up and down arrows to navigate, Enter to open an entry, and Escape to dismiss the picker and return focus to the pill.")); - content.push(localize('sessionsChat.conversations', "When a session supports multiple chats, a New Chat button is always shown: as a labeled button in the session header while the session has a single visible chat tab, and as a compact button at the end of the chat tab strip once the session has more than one visible chat tab. Activate it to start a new chat. A Chats dropdown is also shown in the session header meta row, at the end of the pills, once the session has more than one committed chat or the active chat has subagents. Side chats appear as first-level chats. A Subagents group lists work delegated by the active chat, and every item announces its state. When there is one first-level chat, only its Subagents are listed. The active chat or subagent is selected when the dropdown opens. Select an item to open or focus it.")); + content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach metadata and status pills above it, then press Enter or Space to activate a pill. Live browsers appear in their own pill, and background activities such as running subagents in another. A pill with more than one entry opens a picker; use the up and down arrows to navigate, Enter to open an entry, and Escape to dismiss the picker and return focus to the pill.")); + content.push(localize('sessionsChat.conversations', "When multiple chats appear as tabs in a single group, the tab row replaces the session header and includes the session actions. Side-by-side chat groups retain the session header and keep their tab rows compact. Activate New Chat at the end of a tab row to start another chat in that group.")); content.push(localize('sessionsChat.subagentPills', "Subagent pills in the chat transcript can be dragged to a chat group's edge to open the subagent beside the current chat. With the keyboard, focus a subagent pill and press Alt+Enter to open it beside the current chat.")); content.push(localize('sessionsChat.chatGroups', "Chats can be arranged in groups. Focus the previous group{0} or next group{1}. Split the active chat into a group to the right{2} or below{3}, or move it to the previous group{4} or next group{5}.", ``, ``, ``, ``, ``, ``)); content.push(localize('sessionsChat.closeChat', "Activate a chat tab's close button to close (hide) that chat from the tab strip without deleting it; reopen it later from the Chats menu. The session's main chat cannot be closed.")); diff --git a/src/vs/sessions/contrib/chat/common/sessionChatPills.ts b/src/vs/sessions/contrib/chat/common/sessionChatPills.ts index d44449367ad6a2..188d8054a80975 100644 --- a/src/vs/sessions/contrib/chat/common/sessionChatPills.ts +++ b/src/vs/sessions/contrib/chat/common/sessionChatPills.ts @@ -57,8 +57,6 @@ export interface ISessionChatPillMenuEntry { readonly label: string; /** Whether the pill shows when it has data. */ readonly checked: boolean; - /** Kinds without data cannot be toggled. */ - readonly enabled: boolean; } /** @@ -73,8 +71,8 @@ export interface ISessionChatPillMenu { } /** - * Builds the visibility menu. Every hideable kind is listed, checked while it is - * not hidden, and disabled while the session reports no data for it. + * Builds the visibility menu. Every hideable kind is listed and toggleable, + * checked while it is not hidden, grouped by whether the session has data for it. * * @param targetKind The pill that was right-clicked, which gains a "Hide X" * entry. Omitted when the click did not land on a pill. @@ -90,12 +88,10 @@ export function getSessionChatPillMenu( if (!isSessionChatPillHideable(kind)) { continue; } - const enabled = kindsWithData.has(kind); - (enabled ? withData : withoutData).push({ + (kindsWithData.has(kind) ? withData : withoutData).push({ kind, label: getSessionChatPillLabel(kind), checked: !hiddenKinds.has(kind), - enabled, }); } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts index cdda336a07b8ce..0472982acac96f 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { isMarkdownString } from '../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { buildSessionArtifactSections, type ISessionArtifactActions } from '../../browser/sessionArtifacts.js'; +import { buildSessionArtifactSections, type ISessionArtifactActions, type ISessionArtifactImage } from '../../browser/sessionArtifacts.js'; import { type ISessionArtifact, SessionArtifactKind, SessionFileOperation } from '../../../../services/sessions/common/session.js'; suite('Session Artifacts', () => { @@ -16,6 +16,7 @@ suite('Session Artifacts', () => { const actions: ISessionArtifactActions = { openExternal() { }, openResource() { }, + openImages() { }, copy() { }, }; @@ -30,7 +31,7 @@ suite('Session Artifacts', () => { { id: 'resource', kind: SessionArtifactKind.Resource, label: 'Resource', uri: resourceUri }, ]; - const entries = buildSessionArtifactSections(artifacts, [{ uri: externalFileUri, operation: SessionFileOperation.Created }], actions).flatMap(section => section.entries); + const entries = buildSessionArtifactSections(artifacts, [{ uri: externalFileUri, operation: SessionFileOperation.Created }], actions, true).flatMap(section => section.entries); assert.deepStrictEqual(entries.map(entry => { const content = entry.hover?.content; return { @@ -47,4 +48,63 @@ suite('Session Artifacts', () => { { label: 'Resource', ariaLabel: 'Open Resource', ariaDescription: resourceUri.toString(true), hover: resourceUri.toString(true), tooltip: resourceUri.toString(true) }, ]); }); + + test('groups artifact images separately and opens all images in the carousel', () => { + const screenshotUri = URI.file('/artifacts/screenshot.png'); + const diagramUri = URI.file('/external/diagram.jpg'); + const reportUri = URI.file('/artifacts/report.md'); + const opened: { images: readonly ISessionArtifactImage[]; startIndex: number }[] = []; + const imageActions: ISessionArtifactActions = { + ...actions, + openImages: (images, startIndex) => opened.push({ images, startIndex }), + }; + const artifacts: readonly ISessionArtifact[] = [ + { id: 'screenshot', kind: SessionArtifactKind.File, label: 'Screenshot', uri: screenshotUri }, + { id: 'report', kind: SessionArtifactKind.File, label: 'Report', uri: reportUri }, + ]; + + const sections = buildSessionArtifactSections(artifacts, [ + { uri: diagramUri, operation: SessionFileOperation.Created }, + ], imageActions, true); + const imageSection = sections.find(section => section.title === 'Images'); + assert.ok(imageSection); + imageSection.entries[1].open(); + + assert.deepStrictEqual({ + sections: sections.map(section => ({ title: section.title, labels: section.entries.map(entry => entry.label) })), + opened: opened.map(entry => ({ images: entry.images.map(image => image.uri.path), startIndex: entry.startIndex })), + }, { + sections: [ + { title: 'Images', labels: ['screenshot.png', 'diagram.jpg'] }, + { title: 'Files', labels: ['report.md'] }, + ], + opened: [{ images: ['/artifacts/screenshot.png', '/external/diagram.jpg'], startIndex: 1 }], + }); + }); + + test('opens the image resource when the image carousel is disabled', () => { + const screenshotUri = URI.file('/artifacts/screenshot.png'); + const opened: string[] = []; + const imageActions: ISessionArtifactActions = { + ...actions, + openImages: () => opened.push('carousel'), + openResource: uri => opened.push(uri.path), + }; + const artifacts: readonly ISessionArtifact[] = [ + { id: 'screenshot', kind: SessionArtifactKind.File, label: 'Screenshot', uri: screenshotUri }, + ]; + + const sections = buildSessionArtifactSections(artifacts, [], imageActions, false); + const imageSection = sections.find(section => section.title === 'Images'); + assert.ok(imageSection); + imageSection.entries[0].open(); + + assert.deepStrictEqual({ + ariaLabel: imageSection.entries[0].ariaLabel, + opened, + }, { + ariaLabel: 'Open screenshot.png', + opened: ['/artifacts/screenshot.png'], + }); + }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts index ccf9e8130b85d0..0beae6ba6931a0 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -9,7 +9,7 @@ import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID } from '../../../ import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../../changes/common/changes.js'; import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../../github/common/types.js'; import { SessionChatPillKind } from '../../common/sessionChatPills.js'; -import { getSessionChatPillKindForAction, SESSION_BROWSERS_PILL_ID, SESSION_SUBAGENTS_PILL_ID, shouldShowSessionTurnPills } from '../../browser/sessionChatInputToolbar.js'; +import { getSessionChatPillKindForAction, SESSION_BROWSERS_PILL_ID, SESSION_SUBAGENTS_PILL_ID } from '../../browser/sessionChatInputToolbar.js'; import { SESSION_CUSTOMIZATIONS_PILL_ID } from '../../browser/sessionCustomizations.js'; suite('SessionChatInputToolbar', () => { @@ -38,18 +38,4 @@ suite('SessionChatInputToolbar', () => { undefined, ]); }); - - test('keeps last-turn pills visible after completion only in metadata-input placement', () => { - assert.deepStrictEqual([ - shouldShowSessionTurnPills(false, false, false, true), - shouldShowSessionTurnPills(false, false, true, true), - shouldShowSessionTurnPills(false, true, false, true), - shouldShowSessionTurnPills(false, false, true, false), - ], [ - false, - true, - true, - false, - ]); - }); }); diff --git a/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts b/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts index f3d44b8f17bc30..a0e175a04e8606 100644 --- a/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts +++ b/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts @@ -19,14 +19,14 @@ suite('SessionChatPills', () => { assert.deepStrictEqual(menu, { withData: [ - { kind: SessionChatPillKind.PullRequests, label: 'Pull Requests', checked: false, enabled: true }, - { kind: SessionChatPillKind.Subagents, label: 'Subagents', checked: true, enabled: true }, + { kind: SessionChatPillKind.PullRequests, label: 'Pull Requests', checked: false }, + { kind: SessionChatPillKind.Subagents, label: 'Subagents', checked: true }, ], withoutData: [ - { kind: SessionChatPillKind.Artifacts, label: 'Artifacts', checked: true, enabled: false }, - { kind: SessionChatPillKind.Customizations, label: 'Customizations', checked: true, enabled: false }, - { kind: SessionChatPillKind.Issues, label: 'Issues', checked: true, enabled: false }, - { kind: SessionChatPillKind.Browsers, label: 'Browsers', checked: true, enabled: false }, + { kind: SessionChatPillKind.Artifacts, label: 'Artifacts', checked: true }, + { kind: SessionChatPillKind.Customizations, label: 'Customizations', checked: true }, + { kind: SessionChatPillKind.Issues, label: 'Issues', checked: true }, + { kind: SessionChatPillKind.Browsers, label: 'Browsers', checked: true }, ], }); }); diff --git a/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts b/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts index 5d7e4c43006667..52bc9847437bf1 100644 --- a/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts +++ b/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts @@ -27,7 +27,6 @@ import { getSessionWorkspaceDisplayInfo, ISessionWorkspaceDisplayInfo } from '.. import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; import { SessionHasWorkspaceContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { NEW_FILE_TAB_COMMAND_ID } from '../../../common/sessionCommands.js'; -import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; @@ -44,9 +43,7 @@ export class OpenFilesViewAction extends Action2 { title: localize2('agentSessions.files', 'Files'), icon: Codicon.folder, f1: false, - // Workspace folder pill shown in the session header meta row - // (vs/sessions/browser/parts/sessionHeader.ts), rendered with a custom - // action view item. Ordered before the changes pill (order 0). + // Workspace metadata pill, ordered before changes. menu: { id: Menus.SessionHeaderMeta, group: 'navigation', @@ -54,7 +51,6 @@ export class OpenFilesViewAction extends Action2 { when: ContextKeyExpr.and( SessionHasWorkspaceContext, IsQuickChatSessionContext.negate(), - ContextKeyExpr.notEquals(`config.${SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING}`, true), ) }, }); @@ -66,9 +62,8 @@ export class OpenFilesViewAction extends Action2 { const commandService = accessor.get(ICommandService); const layoutService = accessor.get(IAgentWorkbenchLayoutService); - // The clicked session is forwarded as the argument by the session header, - // which has already promoted it to be the active session. Fall back to the - // active session when invoked without an explicit argument. + // The clicked pill forwards its session. Fall back to the active session + // when invoked without an explicit argument. const targetSession = session ?? sessionsService.activeSession.get(); if (!targetSession) { return; @@ -83,13 +78,11 @@ export class OpenFilesViewAction extends Action2 { } registerAction2(OpenFilesViewAction); -// --- Open Files view action view item (session header workspace folder pill) +// --- Open Files view action view item /** - * Renders the session's workspace folder as a `