From 86c38e4e23488be1f4f6ef6a25859141f2c728dc Mon Sep 17 00:00:00 2001 From: wenyutang Date: Thu, 18 Sep 2025 15:19:57 +0800 Subject: [PATCH 01/13] feat: context provider api support for java --- package-lock.json | 110 ++++++++++++++ package.json | 1 + src/commands/handler.ts | 4 + src/copilot/context/contextCache.ts | 211 +++++++++++++++++++++++++++ src/copilot/context/copilotHelper.ts | 28 ++++ src/copilot/contextProvider.ts | 207 ++++++++++++++++++++++++++ src/exp/TreatmentVariables.ts | 1 + 7 files changed, 562 insertions(+) create mode 100644 src/copilot/context/contextCache.ts create mode 100644 src/copilot/context/copilotHelper.ts create mode 100644 src/copilot/contextProvider.ts diff --git a/package-lock.json b/package-lock.json index a3087c63..a635c0ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.29.2", "license": "MIT", "dependencies": { + "@github/copilot-language-server": "^1.316.0", "@iconify-icons/codicon": "1.2.8", "@iconify/react": "^1.1.4", "@reduxjs/toolkit": "^1.8.6", @@ -128,6 +129,90 @@ "node": ">=10.0.0" } }, + "node_modules/@github/copilot-language-server": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.373.0.tgz", + "integrity": "sha512-tcRyxEvm36M30x5v3u/OuPnPENZJsmbMkcY+6A45Fsr0ZtUJF7BtAS/Si/2QTCVJndA2Oi7taicIuqSDucAR/Q==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "^3.17.5" + }, + "bin": { + "copilot-language-server": "dist/language-server.js" + }, + "optionalDependencies": { + "@github/copilot-language-server-darwin-arm64": "1.373.0", + "@github/copilot-language-server-darwin-x64": "1.373.0", + "@github/copilot-language-server-linux-arm64": "1.373.0", + "@github/copilot-language-server-linux-x64": "1.373.0", + "@github/copilot-language-server-win32-x64": "1.373.0" + } + }, + "node_modules/@github/copilot-language-server-darwin-arm64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.373.0.tgz", + "integrity": "sha512-pzZZnQX3jIYmQ0/LgcB54xfnbFTmCmymSL1v5OemH9qpG3Xi4ekTnRy/YRGStxHAbM5mvPX9QDJJ+/CFTvSBGg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-darwin-x64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.373.0.tgz", + "integrity": "sha512-1yfXy5cum7it3jUJ43ruymtj9StERUPEEY2nM9lCGgtv+Wn7ip0k2IFQvzfp/ql0FCivH0O954pqkrHO7GUYZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@github/copilot-language-server-linux-arm64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.373.0.tgz", + "integrity": "sha512-dijhk5AlP3SQuECFXEHyNlzGxV0HClWM3yP54pod8Wu3Yb6Xo5Ek9ClEiNPc1f0FOiVT3DJ0ldmtm6Tb2/2xTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-linux-x64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.373.0.tgz", + "integrity": "sha512-YCjhxglxPEneJUAycT90GWpNpswWsl1/RCYe7hG7lxKN6At0haE9XF/i/bisvwyqSBB9vUOFp2TB/XhwD9dQWg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@github/copilot-language-server-win32-x64": { + "version": "1.373.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.373.0.tgz", + "integrity": "sha512-lxMIjKwVbpg2JAgo11Ddwv7i0FSgCxjC+2XG6f/3ItG8M0dRkGzJzVNl9sQaTbZPria8T4vNB9nRM0Lpe92LUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@iconify-icons/codicon": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@iconify-icons/codicon/-/codicon-1.2.8.tgz", @@ -3594,6 +3679,31 @@ "uuid": "^8.3.2" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, "node_modules/vscode-tas-client": { "version": "0.1.84", "resolved": "https://registry.npmjs.org/vscode-tas-client/-/vscode-tas-client-0.1.84.tgz", diff --git a/package.json b/package.json index 56049d14..a74aa2b8 100644 --- a/package.json +++ b/package.json @@ -381,6 +381,7 @@ "VisualStudioExptTeam.vscodeintellicode" ], "dependencies": { + "@github/copilot-language-server": "^1.316.0", "@iconify-icons/codicon": "1.2.8", "@iconify/react": "^1.1.4", "@reduxjs/toolkit": "^1.8.6", diff --git a/src/commands/handler.ts b/src/commands/handler.ts index 84825d6e..71dda9e5 100644 --- a/src/commands/handler.ts +++ b/src/commands/handler.ts @@ -127,3 +127,7 @@ export async function toggleAwtDevelopmentHandler(context: vscode.ExtensionConte fetchInitProps(context); vscode.window.showInformationMessage(`Java AWT development is ${enable ? "enabled" : "disabled"}.`); } + +export async function getImportClassContent(uri: string): Promise { + return await vscode.commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.JAVA_PROJECT_GETIMPORTCLASSCONTENT, uri) || []; +} \ No newline at end of file diff --git a/src/copilot/context/contextCache.ts b/src/copilot/context/contextCache.ts new file mode 100644 index 00000000..c2fbacd2 --- /dev/null +++ b/src/copilot/context/contextCache.ts @@ -0,0 +1,211 @@ +/*--------------------------------------------------------------------------------------------- + * 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 crypto from 'crypto'; +import { INodeImportClass } from './copilotHelper'; + +/** + * Cache entry interface for storing import data with timestamp + */ +interface CacheEntry { + value: INodeImportClass[]; + timestamp: number; +} + +/** + * Configuration options for the context cache + */ +interface ContextCacheOptions { + /** Cache expiry time in milliseconds. Default: 5 minutes */ + expiryTime?: number; + /** Enable automatic cleanup interval. Default: true */ + enableAutoCleanup?: boolean; + /** Enable file watching for cache invalidation. Default: true */ + enableFileWatching?: boolean; +} + +/** + * Context cache manager for storing and managing Java import contexts + */ +export class ContextCache { + private readonly cache = new Map(); + private readonly expiryTime: number; + private readonly enableAutoCleanup: boolean; + private readonly enableFileWatching: boolean; + + private cleanupInterval?: NodeJS.Timeout; + private fileWatcher?: vscode.FileSystemWatcher; + + constructor(options: ContextCacheOptions = {}) { + this.expiryTime = options.expiryTime ?? 5 * 60 * 1000; // 5 minutes default + this.enableAutoCleanup = options.enableAutoCleanup ?? true; + this.enableFileWatching = options.enableFileWatching ?? true; + } + + /** + * Initialize the cache with VS Code extension context + * @param context VS Code extension context for managing disposables + */ + public initialize(context: vscode.ExtensionContext): void { + if (this.enableAutoCleanup) { + this.startPeriodicCleanup(); + } + + if (this.enableFileWatching) { + this.setupFileWatcher(); + } + + // Register cleanup on extension disposal + context.subscriptions.push( + new vscode.Disposable(() => { + this.dispose(); + }) + ); + + if (this.fileWatcher) { + context.subscriptions.push(this.fileWatcher); + } + } + + /** + * Generate a hash for the document URI to use as cache key + * @param uri Document URI + * @returns Hashed URI string + */ + private generateCacheKey(uri: vscode.Uri): string { + return crypto.createHash('md5').update(uri.toString()).digest('hex'); + } + + /** + * Get cached imports for a document URI + * @param uri Document URI + * @returns Cached imports or null if not found/expired + */ + public get(uri: vscode.Uri): INodeImportClass[] | null { + const key = this.generateCacheKey(uri); + const cached = this.cache.get(key); + + if (!cached) { + return null; + } + + // Check if cache is expired + if (this.isExpired(cached)) { + this.cache.delete(key); + return null; + } + + return cached.value; + } + + /** + * Set cached imports for a document URI + * @param uri Document URI + * @param imports Import class array to cache + */ + public set(uri: vscode.Uri, imports: INodeImportClass[]): void { + const key = this.generateCacheKey(uri); + this.cache.set(key, { + value: imports, + timestamp: Date.now() + }); + } + + /** + * Check if a cache entry is expired + * @param entry Cache entry to check + * @returns True if expired, false otherwise + */ + private isExpired(entry: CacheEntry): boolean { + return Date.now() - entry.timestamp > this.expiryTime; + } + + /** + * Clear expired cache entries + */ + public clearExpired(): void { + const now = Date.now(); + for (const [key, entry] of this.cache.entries()) { + if (now - entry.timestamp > this.expiryTime) { + this.cache.delete(key); + } + } + } + + /** + * Clear all cache entries + */ + public clear(): void { + this.cache.clear(); + } + + /** + * Invalidate cache for specific URI + * @param uri URI to invalidate + */ + public invalidate(uri: vscode.Uri): void { + const key = this.generateCacheKey(uri); + if (this.cache.has(key)) { + this.cache.delete(key); + console.log('======== Cache invalidated for:', uri.toString()); + } + } + + /** + * Get cache statistics + * @returns Object containing cache size and other statistics + */ + public getStats(): { size: number; expiryTime: number } { + return { + size: this.cache.size, + expiryTime: this.expiryTime + }; + } + + /** + * Start periodic cleanup of expired cache entries + */ + private startPeriodicCleanup(): void { + this.cleanupInterval = setInterval(() => { + this.clearExpired(); + }, this.expiryTime); + } + + /** + * Setup file system watcher for Java files to invalidate cache on changes + */ + private setupFileWatcher(): void { + this.fileWatcher = vscode.workspace.createFileSystemWatcher('**/*.java'); + + const invalidateHandler = (uri: vscode.Uri) => { + this.invalidate(uri); + }; + + this.fileWatcher.onDidChange(invalidateHandler); + this.fileWatcher.onDidDelete(invalidateHandler); + } + + /** + * Dispose of all resources (intervals, watchers, etc.) + */ + public dispose(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = undefined; + } + + if (this.fileWatcher) { + this.fileWatcher.dispose(); + this.fileWatcher = undefined; + } + + this.clear(); + } +} + +/** + * Default context cache instance + */ +export const contextCache = new ContextCache(); diff --git a/src/copilot/context/copilotHelper.ts b/src/copilot/context/copilotHelper.ts new file mode 100644 index 00000000..87dd1475 --- /dev/null +++ b/src/copilot/context/copilotHelper.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import { commands, Uri } from "vscode"; +import { logger } from "../utils"; + +export interface INodeImportClass { + uri: string; + className: string; // Changed from 'class' to 'className' to match Java code +} +/** + * Helper class for Copilot integration to analyze Java project dependencies + */ +export namespace CopilotHelper { + /** + * Resolves all local project types imported by the given file + * @param fileUri The URI of the Java file to analyze + * @returns Array of strings in format "type:fully.qualified.name" where type is class|interface|enum|annotation + */ + export async function resolveLocalImports(fileUri: Uri): Promise { + try { + return await commands.executeCommand("java.execute.workspaceCommand", "java.project.getImportClassContent", fileUri) || []; + } catch (error) { + logger.error("Error resolving copilot request:", error); + return []; + } + } +} diff --git a/src/copilot/contextProvider.ts b/src/copilot/contextProvider.ts new file mode 100644 index 00000000..88e229ad --- /dev/null +++ b/src/copilot/contextProvider.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { + ContextProviderApiV1, + ResolveRequest, + SupportedContextItem, + type ContextProvider, +} from '@github/copilot-language-server'; +import * as vscode from 'vscode'; +import { CopilotHelper } from './context/copilotHelper'; +import { sendInfo } from "vscode-extension-telemetry-wrapper"; +import { contextCache } from './context/contextCache'; +import { TreatmentVariables } from '../exp/TreatmentVariables'; +import { getExpService } from '../exp'; +import { logger, getProjectJavaVersion } from './utils'; +import { getExtensionName } from '../utils/extension'; + +export async function registerCopilotContextProviders( + context: vscode.ExtensionContext +) { + const contextProviderIsEnabled = await getExpService().getTreatmentVariableAsync(TreatmentVariables.VSCodeConfig, TreatmentVariables.ContextProvider, true); + if (!contextProviderIsEnabled) { + sendInfo("", { + "contextProviderEnabled": "false", + }); + return; + } + sendInfo("", { + "contextProviderEnabled": "true", + }); + + // Initialize the context cache + contextCache.initialize(context); + + try { + const copilotClientApi = await getCopilotClientApi(); + const copilotChatApi = await getCopilotChatApi(); + if (!copilotClientApi || !copilotChatApi) { + logger.error('Failed to find compatible version of GitHub Copilot extension installed. Skip registration of Copilot context provider.'); + return; + } + // Register the Java completion context provider + const provider: ContextProvider = { + id: getExtensionName(), // use extension id as provider id for now + selector: [{ language: "java" }], + resolver: { + resolve: async (request, token) => { + // Check if we have a cached result for the current active editor + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor && activeEditor.document.languageId === 'java') { + const cachedImports = contextCache.get(activeEditor.document.uri); + if (cachedImports) { + logger.info('======== Using cached imports, cache size:', cachedImports.length); + // Return cached result as context items + return cachedImports.map((cls: any) => ({ + uri: cls.uri, + value: cls.className, + importance: 70, + origin: 'request' as const + })); + } + } + + return await resolveJavaContext(request, token); + } + } + }; + + let installCount = 0; + if (copilotClientApi) { + const disposable = await installContextProvider(copilotClientApi, provider); + if (disposable) { + context.subscriptions.push(disposable); + installCount++; + } + } + if (copilotChatApi) { + const disposable = await installContextProvider(copilotChatApi, provider); + if (disposable) { + context.subscriptions.push(disposable); + installCount++; + } + } + + if (installCount === 0) { + logger.warn('Incompatible GitHub Copilot extension installed. Skip registration of Java context providers.'); + return; + } + logger.info('Registration of Java context provider for GitHub Copilot extension succeeded.'); + } + catch (error) { + logger.error('Error occurred while registering Java context provider for GitHub Copilot extension:', error); + } +} + +async function resolveJavaContext(_request: ResolveRequest, _token: vscode.CancellationToken): Promise { + const items: SupportedContextItem[] = []; + const start = performance.now(); + try { + // Get current document and position information + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor || activeEditor.document.languageId !== 'java') { + return items; + } + + const document = activeEditor.document; + + // 1. Project basic information (High importance) + const javaVersion = await getProjectJavaVersion(document); + + items.push({ + name: 'java.version', + value: javaVersion, + importance: 90, + id: 'java-version', + origin: 'request' + }); + + items.push({ + name: 'java.file', + value: vscode.workspace.asRelativePath(document.uri), + importance: 80, + id: 'java-file-path', + origin: 'request' + }); + + // Try to get cached imports first + let importClass = contextCache.get(document.uri); + if (!importClass) { + // If not cached, resolve and cache the result + importClass = await CopilotHelper.resolveLocalImports(document.uri); + if (importClass) { + contextCache.set(document.uri, importClass); + logger.info('======== Cached new imports, cache size:', importClass.length); + } + } else { + logger.info('======== Using cached imports in resolveJavaContext, cache size:', importClass.length); + } + + if (importClass) { + for (const cls of importClass) { + items.push({ + uri: cls.uri, + value: cls.className, + importance: 70, + origin: 'request' + }); + } + } + } catch (error) { + logger.error('Error resolving Java context:', error); + } + logger.info('Total context resolution time:', performance.now() - start, 'ms', ' ,size:', items.length); + logger.info('Context items:', items); + return items; +} + +interface CopilotApi { + getContextProviderAPI(version: string): Promise; +} + +async function getCopilotClientApi(): Promise { + const extension = vscode.extensions.getExtension('github.copilot'); + if (!extension) { + return undefined; + } + try { + return await extension.activate(); + } catch { + return undefined; + } +} + +async function getCopilotChatApi(): Promise { + type CopilotChatApi = { getAPI?(version: number): CopilotApi | undefined }; + const extension = vscode.extensions.getExtension('github.copilot-chat'); + if (!extension) { + return undefined; + } + + let exports: CopilotChatApi | undefined; + try { + exports = await extension.activate(); + } catch { + return undefined; + } + if (!exports || typeof exports.getAPI !== 'function') { + return undefined; + } + return exports.getAPI(1); +} + +async function installContextProvider( + copilotAPI: CopilotApi, + contextProvider: ContextProvider +): Promise { + const hasGetContextProviderAPI = typeof copilotAPI.getContextProviderAPI === 'function'; + if (hasGetContextProviderAPI) { + const contextAPI = await copilotAPI.getContextProviderAPI('v1'); + if (contextAPI) { + return contextAPI.registerContextProvider(contextProvider); + } + } + return undefined; +} diff --git a/src/exp/TreatmentVariables.ts b/src/exp/TreatmentVariables.ts index 88667953..e1a458d8 100644 --- a/src/exp/TreatmentVariables.ts +++ b/src/exp/TreatmentVariables.ts @@ -6,4 +6,5 @@ export class TreatmentVariables { public static readonly PresentWelcomePageByDefault = 'presentWelcomePageByDefault'; public static readonly JavaWalkthroughEnabled = "gettingStarted.overrideCategory.vscjava.vscode-java-pack.javaWelcome.when"; public static readonly JavaCompletionSampling = "javaCompletionSampling"; + public static readonly ContextProvider = "ContextProviderIsEnabled"; } From 0d78220612681c3b163b16290a557bc88d90cbd0 Mon Sep 17 00:00:00 2001 From: wenyutang Date: Thu, 18 Sep 2025 15:24:42 +0800 Subject: [PATCH 02/13] feat: register registerCopilotContextProviders --- src/extension.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index 43f65356..431cf32c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,6 +24,7 @@ import { scheduleAction } from "./utils/scheduler"; import { showWelcomeWebview, WelcomeViewSerializer } from "./welcome"; import { ProjectSettingsViewSerializer } from "./project-settings/projectSettingsView"; import { TelemetryFilter } from "./utils/telemetryFilter"; +import { registerCopilotContextProviders } from "./copilot/contextProvider"; let cleanJavaWorkspaceIndicator: string; let activatedTimestamp: number; @@ -82,6 +83,8 @@ async function initializeExtension(_operationId: string, context: vscode.Extensi vscode.commands.executeCommand("java.runtime"); }); } + + await registerCopilotContextProviders(context); } async function presentFirstView(context: vscode.ExtensionContext) { From 1896f004f7b518ef826399f6ea32de7f6e269c99 Mon Sep 17 00:00:00 2001 From: wenyutang Date: Thu, 18 Sep 2025 15:25:47 +0800 Subject: [PATCH 03/13] feat: register registerCopilotContextProviders --- src/commands/handler.ts | 4 ---- src/copilot/context/contextCache.ts | 2 +- src/copilot/contextProvider.ts | 8 ++++---- src/exp/TreatmentVariables.ts | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/commands/handler.ts b/src/commands/handler.ts index 71dda9e5..75a7bd32 100644 --- a/src/commands/handler.ts +++ b/src/commands/handler.ts @@ -126,8 +126,4 @@ export async function toggleAwtDevelopmentHandler(context: vscode.ExtensionConte fetchInitProps(context); vscode.window.showInformationMessage(`Java AWT development is ${enable ? "enabled" : "disabled"}.`); -} - -export async function getImportClassContent(uri: string): Promise { - return await vscode.commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.JAVA_PROJECT_GETIMPORTCLASSCONTENT, uri) || []; } \ No newline at end of file diff --git a/src/copilot/context/contextCache.ts b/src/copilot/context/contextCache.ts index c2fbacd2..6ca053f0 100644 --- a/src/copilot/context/contextCache.ts +++ b/src/copilot/context/contextCache.ts @@ -149,7 +149,7 @@ export class ContextCache { const key = this.generateCacheKey(uri); if (this.cache.has(key)) { this.cache.delete(key); - console.log('======== Cache invalidated for:', uri.toString()); + console.log('Cache invalidated for:', uri.toString()); } } diff --git a/src/copilot/contextProvider.ts b/src/copilot/contextProvider.ts index 88e229ad..035b791c 100644 --- a/src/copilot/contextProvider.ts +++ b/src/copilot/contextProvider.ts @@ -20,7 +20,7 @@ import { getExtensionName } from '../utils/extension'; export async function registerCopilotContextProviders( context: vscode.ExtensionContext ) { - const contextProviderIsEnabled = await getExpService().getTreatmentVariableAsync(TreatmentVariables.VSCodeConfig, TreatmentVariables.ContextProvider, true); + const contextProviderIsEnabled = await getExpService().getTreatmentVariableAsync(TreatmentVariables.VSCodeConfig, TreatmentVariables.ContextProviderEnabled, true); if (!contextProviderIsEnabled) { sendInfo("", { "contextProviderEnabled": "false", @@ -52,7 +52,7 @@ export async function registerCopilotContextProviders( if (activeEditor && activeEditor.document.languageId === 'java') { const cachedImports = contextCache.get(activeEditor.document.uri); if (cachedImports) { - logger.info('======== Using cached imports, cache size:', cachedImports.length); + logger.info('Using cached imports, cache size:', cachedImports.length); // Return cached result as context items return cachedImports.map((cls: any) => ({ uri: cls.uri, @@ -133,10 +133,10 @@ async function resolveJavaContext(_request: ResolveRequest, _token: vscode.Cance importClass = await CopilotHelper.resolveLocalImports(document.uri); if (importClass) { contextCache.set(document.uri, importClass); - logger.info('======== Cached new imports, cache size:', importClass.length); + logger.info('Cached new imports, cache size:', importClass.length); } } else { - logger.info('======== Using cached imports in resolveJavaContext, cache size:', importClass.length); + logger.info('Using cached imports in resolveJavaContext, cache size:', importClass.length); } if (importClass) { diff --git a/src/exp/TreatmentVariables.ts b/src/exp/TreatmentVariables.ts index e1a458d8..f5b9ab3b 100644 --- a/src/exp/TreatmentVariables.ts +++ b/src/exp/TreatmentVariables.ts @@ -6,5 +6,5 @@ export class TreatmentVariables { public static readonly PresentWelcomePageByDefault = 'presentWelcomePageByDefault'; public static readonly JavaWalkthroughEnabled = "gettingStarted.overrideCategory.vscjava.vscode-java-pack.javaWelcome.when"; public static readonly JavaCompletionSampling = "javaCompletionSampling"; - public static readonly ContextProvider = "ContextProviderIsEnabled"; + public static readonly ContextProviderEnabled = "ContextProviderIsEnabled"; } From 1d257e963526909c1a06608b6812a7594093fe19 Mon Sep 17 00:00:00 2001 From: wenyutang Date: Fri, 19 Sep 2025 08:50:46 +0800 Subject: [PATCH 04/13] feat: update --- src/copilot/contextProvider.ts | 19 ++++++------------- src/exp/TreatmentVariables.ts | 1 - 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/copilot/contextProvider.ts b/src/copilot/contextProvider.ts index 035b791c..c283e097 100644 --- a/src/copilot/contextProvider.ts +++ b/src/copilot/contextProvider.ts @@ -12,25 +12,12 @@ import * as vscode from 'vscode'; import { CopilotHelper } from './context/copilotHelper'; import { sendInfo } from "vscode-extension-telemetry-wrapper"; import { contextCache } from './context/contextCache'; -import { TreatmentVariables } from '../exp/TreatmentVariables'; -import { getExpService } from '../exp'; import { logger, getProjectJavaVersion } from './utils'; import { getExtensionName } from '../utils/extension'; export async function registerCopilotContextProviders( context: vscode.ExtensionContext ) { - const contextProviderIsEnabled = await getExpService().getTreatmentVariableAsync(TreatmentVariables.VSCodeConfig, TreatmentVariables.ContextProviderEnabled, true); - if (!contextProviderIsEnabled) { - sendInfo("", { - "contextProviderEnabled": "false", - }); - return; - } - sendInfo("", { - "contextProviderEnabled": "true", - }); - // Initialize the context cache contextCache.initialize(context); @@ -89,6 +76,12 @@ export async function registerCopilotContextProviders( return; } logger.info('Registration of Java context provider for GitHub Copilot extension succeeded.'); + sendInfo("", { + "action": "registerCopilotContextProvider", + "extension": getExtensionName(), + "status": "succeeded", + "installCount": installCount + }); } catch (error) { logger.error('Error occurred while registering Java context provider for GitHub Copilot extension:', error); diff --git a/src/exp/TreatmentVariables.ts b/src/exp/TreatmentVariables.ts index f5b9ab3b..88667953 100644 --- a/src/exp/TreatmentVariables.ts +++ b/src/exp/TreatmentVariables.ts @@ -6,5 +6,4 @@ export class TreatmentVariables { public static readonly PresentWelcomePageByDefault = 'presentWelcomePageByDefault'; public static readonly JavaWalkthroughEnabled = "gettingStarted.overrideCategory.vscjava.vscode-java-pack.javaWelcome.when"; public static readonly JavaCompletionSampling = "javaCompletionSampling"; - public static readonly ContextProviderEnabled = "ContextProviderIsEnabled"; } From 42a47ce99c647ab30f75cdb21ee665facd066e26 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Mon, 22 Sep 2025 11:23:32 +0800 Subject: [PATCH 05/13] fix: update code according to the comments --- src/copilot/context/contextCache.ts | 242 +++++++++++++++++++++++++-- src/copilot/context/copilotHelper.ts | 8 +- src/copilot/contextProvider.ts | 33 ++-- 3 files changed, 252 insertions(+), 31 deletions(-) diff --git a/src/copilot/context/contextCache.ts b/src/copilot/context/contextCache.ts index 6ca053f0..536fb190 100644 --- a/src/copilot/context/contextCache.ts +++ b/src/copilot/context/contextCache.ts @@ -5,25 +5,47 @@ import * as vscode from 'vscode'; import * as crypto from 'crypto'; import { INodeImportClass } from './copilotHelper'; - +import { logger } from "../utils"; /** - * Cache entry interface for storing import data with timestamp + * Cache entry interface for storing import data with enhanced metadata */ interface CacheEntry { + /** Unique cache entry ID for tracking */ + id: string; + /** Cached import data */ value: INodeImportClass[]; + /** Creation timestamp */ timestamp: number; + /** Document version when cached */ + documentVersion?: number; + /** Last access timestamp */ + lastAccess: number; + /** File content hash for change detection */ + contentHash?: string; + /** Caret offset when cached (for position-sensitive invalidation) */ + caretOffset?: number; } /** * Configuration options for the context cache */ interface ContextCacheOptions { - /** Cache expiry time in milliseconds. Default: 5 minutes */ + /** Cache expiry time in milliseconds. Default: 10 minutes */ expiryTime?: number; /** Enable automatic cleanup interval. Default: true */ enableAutoCleanup?: boolean; /** Enable file watching for cache invalidation. Default: true */ enableFileWatching?: boolean; + /** Maximum cache size (number of entries). Default: 100 */ + maxCacheSize?: number; + /** Enable content-based invalidation. Default: true */ + enableContentHashing?: boolean; + /** Cleanup interval in milliseconds. Default: 2 minutes */ + cleanupInterval?: number; + /** Maximum distance from cached caret position before cache becomes stale. Default: 8192 */ + maxCaretDistance?: number; + /** Enable position-sensitive cache invalidation. Default: false */ + enablePositionSensitive?: boolean; } /** @@ -34,14 +56,25 @@ export class ContextCache { private readonly expiryTime: number; private readonly enableAutoCleanup: boolean; private readonly enableFileWatching: boolean; + private readonly maxCacheSize: number; + private readonly enableContentHashing: boolean; + private readonly cleanupIntervalMs: number; + private readonly maxCaretDistance: number; + private readonly enablePositionSensitive: boolean; - private cleanupInterval?: NodeJS.Timeout; + private cleanupTimer?: NodeJS.Timeout; private fileWatcher?: vscode.FileSystemWatcher; + private accessCount = 0; // For statistics tracking constructor(options: ContextCacheOptions = {}) { - this.expiryTime = options.expiryTime ?? 5 * 60 * 1000; // 5 minutes default + this.expiryTime = options.expiryTime ?? 10 * 60 * 1000; // 10 minutes default this.enableAutoCleanup = options.enableAutoCleanup ?? true; this.enableFileWatching = options.enableFileWatching ?? true; + this.maxCacheSize = options.maxCacheSize ?? 100; + this.enableContentHashing = options.enableContentHashing ?? true; + this.cleanupIntervalMs = options.cleanupInterval ?? 2 * 60 * 1000; // 2 minutes + this.maxCaretDistance = options.maxCaretDistance ?? 8192; // Same as CopilotCompletionContextProvider + this.enablePositionSensitive = options.enablePositionSensitive ?? false; } /** @@ -79,11 +112,39 @@ export class ContextCache { } /** - * Get cached imports for a document URI + * Get cached imports for a document URI with enhanced validation * @param uri Document URI + * @param currentCaretOffset Optional current caret offset for position-sensitive validation + * @returns Cached imports or null if not found/expired/stale + */ + public async get(uri: vscode.Uri, currentCaretOffset?: number): Promise { + const key = this.generateCacheKey(uri); + const cached = this.cache.get(key); + + if (!cached) { + return null; + } + + // Check if cache is expired or stale + if (await this.isExpiredOrStale(uri, cached, currentCaretOffset)) { + this.cache.delete(key); + return null; + } + + // Update last access time and increment access count + cached.lastAccess = Date.now(); + this.accessCount++; + + return cached.value; + } + + /** + * Get cached imports synchronously (fallback method for compatibility) + * @param uri Document URI + * @param currentCaretOffset Optional current caret offset for position-sensitive validation * @returns Cached imports or null if not found/expired */ - public get(uri: vscode.Uri): INodeImportClass[] | null { + public getSync(uri: vscode.Uri, currentCaretOffset?: number): INodeImportClass[] | null { const key = this.generateCacheKey(uri); const cached = this.cache.get(key); @@ -91,12 +152,26 @@ export class ContextCache { return null; } - // Check if cache is expired + // Check time-based expiry if (this.isExpired(cached)) { this.cache.delete(key); return null; } + // Check position-sensitive expiry if enabled and caret offsets available + if (this.enablePositionSensitive && + cached.caretOffset !== undefined && + currentCaretOffset !== undefined) { + if (this.isStaleCacheHit(currentCaretOffset, cached.caretOffset)) { + this.cache.delete(key); + return null; + } + } + + // Update last access time and increment access count + cached.lastAccess = Date.now(); + this.accessCount++; + return cached.value; } @@ -104,12 +179,37 @@ export class ContextCache { * Set cached imports for a document URI * @param uri Document URI * @param imports Import class array to cache + * @param documentVersion Optional document version + * @param caretOffset Optional caret offset for position-sensitive caching */ - public set(uri: vscode.Uri, imports: INodeImportClass[]): void { + public async set(uri: vscode.Uri, imports: INodeImportClass[], documentVersion?: number, caretOffset?: number): Promise { const key = this.generateCacheKey(uri); + const now = Date.now(); + + // Check cache size limit and evict if necessary + if (this.cache.size >= this.maxCacheSize) { + this.evictLeastRecentlyUsed(); + } + + // Generate content hash if enabled + let contentHash: string | undefined; + if (this.enableContentHashing) { + try { + const document = await vscode.workspace.openTextDocument(uri); + contentHash = crypto.createHash('md5').update(document.getText()).digest('hex'); + } catch (error) { + logger.error('Failed to generate content hash:', error); + } + } + this.cache.set(key, { + id: crypto.randomUUID(), value: imports, - timestamp: Date.now() + timestamp: now, + lastAccess: now, + documentVersion, + contentHash, + caretOffset }); } @@ -122,6 +222,89 @@ export class ContextCache { return Date.now() - entry.timestamp > this.expiryTime; } + /** + * Check if cache is stale based on caret position (similar to CopilotCompletionContextProvider) + * @param currentCaretOffset Current caret offset + * @param cachedCaretOffset Cached caret offset + * @returns True if stale, false otherwise + */ + private isStaleCacheHit(currentCaretOffset: number, cachedCaretOffset: number): boolean { + return Math.abs(currentCaretOffset - cachedCaretOffset) > this.maxCaretDistance; + } + + /** + * Enhanced expiry check including content changes and position sensitivity + * @param uri Document URI + * @param entry Cache entry to check + * @param currentCaretOffset Optional current caret offset + * @returns True if expired or stale + */ + private async isExpiredOrStale(uri: vscode.Uri, entry: CacheEntry, currentCaretOffset?: number): Promise { + // Check time-based expiry + if (this.isExpired(entry)) { + return true; + } + + // Check position-sensitive expiry if enabled and caret offsets available + if (this.enablePositionSensitive && + entry.caretOffset !== undefined && + currentCaretOffset !== undefined) { + if (this.isStaleCacheHit(currentCaretOffset, entry.caretOffset)) { + return true; + } + } + + // Check content-based changes + if (await this.hasContentChanged(uri, entry)) { + return true; + } + + return false; + } + + /** + * Evict least recently used cache entries when cache is full + */ + private evictLeastRecentlyUsed(): void { + if (this.cache.size === 0) return; + + let oldestTime = Date.now(); + let oldestKey = ''; + + for (const [key, entry] of this.cache.entries()) { + if (entry.lastAccess < oldestTime) { + oldestTime = entry.lastAccess; + oldestKey = key; + } + } + + if (oldestKey) { + this.cache.delete(oldestKey); + logger.trace('Evicted LRU cache entry:', oldestKey); + } + } + + /** + * Check if content has changed by comparing hash + * @param uri Document URI + * @param entry Cache entry to check + * @returns True if content has changed + */ + private async hasContentChanged(uri: vscode.Uri, entry: CacheEntry): Promise { + if (!this.enableContentHashing || !entry.contentHash) { + return false; + } + + try { + const document = await vscode.workspace.openTextDocument(uri); + const currentHash = crypto.createHash('md5').update(document.getText()).digest('hex'); + return currentHash !== entry.contentHash; + } catch (error) { + logger.error('Failed to check content change:', error); + return false; + } + } + /** * Clear expired cache entries */ @@ -149,7 +332,7 @@ export class ContextCache { const key = this.generateCacheKey(uri); if (this.cache.has(key)) { this.cache.delete(key); - console.log('Cache invalidated for:', uri.toString()); + logger.trace('Cache invalidated for:', uri.toString()); } } @@ -157,10 +340,20 @@ export class ContextCache { * Get cache statistics * @returns Object containing cache size and other statistics */ - public getStats(): { size: number; expiryTime: number } { + public getStats(): { + size: number; + expiryTime: number; + accessCount: number; + maxSize: number; + hitRate?: number; + positionSensitive: boolean; + } { return { size: this.cache.size, - expiryTime: this.expiryTime + expiryTime: this.expiryTime, + accessCount: this.accessCount, + maxSize: this.maxCacheSize, + positionSensitive: this.enablePositionSensitive }; } @@ -168,9 +361,9 @@ export class ContextCache { * Start periodic cleanup of expired cache entries */ private startPeriodicCleanup(): void { - this.cleanupInterval = setInterval(() => { + this.cleanupTimer = setInterval(() => { this.clearExpired(); - }, this.expiryTime); + }, this.cleanupIntervalMs); } /** @@ -191,9 +384,9 @@ export class ContextCache { * Dispose of all resources (intervals, watchers, etc.) */ public dispose(): void { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = undefined; + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = undefined; } if (this.fileWatcher) { @@ -209,3 +402,16 @@ export class ContextCache { * Default context cache instance */ export const contextCache = new ContextCache(); + +/** + * Enhanced context cache instance with position-sensitive features enabled + * for more precise code completion context + */ +export const enhancedContextCache = new ContextCache({ + expiryTime: 10 * 60 * 1000, // 10 minutes + enablePositionSensitive: true, + maxCaretDistance: 8192, // Same as CopilotCompletionContextProvider + enableContentHashing: true, + maxCacheSize: 100, + cleanupInterval: 2 * 60 * 1000 // 2 minutes +}); diff --git a/src/copilot/context/copilotHelper.ts b/src/copilot/context/copilotHelper.ts index 87dd1475..4b322c12 100644 --- a/src/copilot/context/copilotHelper.ts +++ b/src/copilot/context/copilotHelper.ts @@ -3,6 +3,7 @@ import { commands, Uri } from "vscode"; import { logger } from "../utils"; +import { validateAndRecommendExtension } from "../../recommendation"; export interface INodeImportClass { uri: string; @@ -11,15 +12,18 @@ export interface INodeImportClass { /** * Helper class for Copilot integration to analyze Java project dependencies */ -export namespace CopilotHelper { +export namespace CopilotHelper { /** * Resolves all local project types imported by the given file * @param fileUri The URI of the Java file to analyze * @returns Array of strings in format "type:fully.qualified.name" where type is class|interface|enum|annotation */ export async function resolveLocalImports(fileUri: Uri): Promise { + if (!await validateAndRecommendExtension("vscjava.vscode-java-dependency", "Project Manager for Java extension is recommended to provide additional Java project explorer features.", true)) { + return []; + } try { - return await commands.executeCommand("java.execute.workspaceCommand", "java.project.getImportClassContent", fileUri) || []; + return await commands.executeCommand("java.execute.workspaceCommand", "java.project.getImportClassContent", fileUri.toString()) || []; } catch (error) { logger.error("Error resolving copilot request:", error); return []; diff --git a/src/copilot/contextProvider.ts b/src/copilot/contextProvider.ts index c283e097..433361b4 100644 --- a/src/copilot/contextProvider.ts +++ b/src/copilot/contextProvider.ts @@ -12,20 +12,20 @@ import * as vscode from 'vscode'; import { CopilotHelper } from './context/copilotHelper'; import { sendInfo } from "vscode-extension-telemetry-wrapper"; import { contextCache } from './context/contextCache'; -import { logger, getProjectJavaVersion } from './utils'; +import { getProjectJavaVersion, logger } from './utils'; import { getExtensionName } from '../utils/extension'; export async function registerCopilotContextProviders( context: vscode.ExtensionContext ) { - // Initialize the context cache + // Initialize the context cache with enhanced options contextCache.initialize(context); try { const copilotClientApi = await getCopilotClientApi(); const copilotChatApi = await getCopilotChatApi(); if (!copilotClientApi || !copilotChatApi) { - logger.error('Failed to find compatible version of GitHub Copilot extension installed. Skip registration of Copilot context provider.'); + logger.info('Failed to find compatible version of GitHub Copilot extension installed. Skip registration of Copilot context provider.'); return; } // Register the Java completion context provider @@ -37,9 +37,13 @@ export async function registerCopilotContextProviders( // Check if we have a cached result for the current active editor const activeEditor = vscode.window.activeTextEditor; if (activeEditor && activeEditor.document.languageId === 'java') { - const cachedImports = contextCache.get(activeEditor.document.uri); + // Get current caret offset for position-sensitive caching + const currentCaretOffset = activeEditor.document.offsetAt(activeEditor.selection.active); + + // Try to get cached imports with position validation + const cachedImports = await contextCache.get(activeEditor.document.uri, currentCaretOffset); if (cachedImports) { - logger.info('Using cached imports, cache size:', cachedImports.length); + logger.trace('Using cached imports, cache size:', cachedImports.length); // Return cached result as context items return cachedImports.map((cls: any) => ({ uri: cls.uri, @@ -72,7 +76,7 @@ export async function registerCopilotContextProviders( } if (installCount === 0) { - logger.warn('Incompatible GitHub Copilot extension installed. Skip registration of Java context providers.'); + logger.info('Incompatible GitHub Copilot extension installed. Skip registration of Java context providers.'); return; } logger.info('Registration of Java context provider for GitHub Copilot extension succeeded.'); @@ -120,13 +124,20 @@ async function resolveJavaContext(_request: ResolveRequest, _token: vscode.Cance }); // Try to get cached imports first - let importClass = contextCache.get(document.uri); + let importClass = await contextCache.get(document.uri); if (!importClass) { // If not cached, resolve and cache the result - importClass = await CopilotHelper.resolveLocalImports(document.uri); - if (importClass) { - contextCache.set(document.uri, importClass); - logger.info('Cached new imports, cache size:', importClass.length); + const resolvedImports = await CopilotHelper.resolveLocalImports(document.uri); + logger.trace('Resolved imports count:', resolvedImports); + if (resolvedImports) { + // Get current caret offset for position-sensitive caching + const currentCaretOffset = vscode.window.activeTextEditor?.document.offsetAt( + vscode.window.activeTextEditor.selection.active + ); + + await contextCache.set(document.uri, resolvedImports, undefined, currentCaretOffset); + importClass = resolvedImports; + logger.trace('Cached new imports, cache size:', importClass.length); } } else { logger.info('Using cached imports in resolveJavaContext, cache size:', importClass.length); From d9824149cf7150279b1e89feb2a51247ffc765c5 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Wed, 24 Sep 2025 10:42:00 +0800 Subject: [PATCH 06/13] fix: update the docu hash logic --- src/copilot/context/contextCache.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/copilot/context/contextCache.ts b/src/copilot/context/contextCache.ts index 536fb190..e2f7e1a9 100644 --- a/src/copilot/context/contextCache.ts +++ b/src/copilot/context/contextCache.ts @@ -191,12 +191,15 @@ export class ContextCache { this.evictLeastRecentlyUsed(); } - // Generate content hash if enabled + // Generate lightweight content hash if enabled let contentHash: string | undefined; if (this.enableContentHashing) { try { const document = await vscode.workspace.openTextDocument(uri); - contentHash = crypto.createHash('md5').update(document.getText()).digest('hex'); + // Use document version and file stats for efficient change detection + const stats = await vscode.workspace.fs.stat(uri); + const hashInput = `${document.version}-${stats.mtime}-${stats.size}`; + contentHash = crypto.createHash('md5').update(hashInput).digest('hex'); } catch (error) { logger.error('Failed to generate content hash:', error); } @@ -285,7 +288,7 @@ export class ContextCache { } /** - * Check if content has changed by comparing hash + * Check if content has changed by comparing lightweight hash * @param uri Document URI * @param entry Cache entry to check * @returns True if content has changed @@ -296,8 +299,16 @@ export class ContextCache { } try { + // Fast check using document version first const document = await vscode.workspace.openTextDocument(uri); - const currentHash = crypto.createHash('md5').update(document.getText()).digest('hex'); + if (entry.documentVersion !== undefined && document.version !== entry.documentVersion) { + return true; + } + + // If document version is the same or not available, check file stats + const stats = await vscode.workspace.fs.stat(uri); + const hashInput = `${document.version}-${stats.mtime}-${stats.size}`; + const currentHash = crypto.createHash('md5').update(hashInput).digest('hex'); return currentHash !== entry.contentHash; } catch (error) { logger.error('Failed to check content change:', error); From c88b1fb0749853f4426790b3e7d68751857d123c Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 25 Sep 2025 09:37:16 +0800 Subject: [PATCH 07/13] fix: update code --- src/copilot/context/copilotHelper.ts | 38 ++++- src/copilot/contextProvider.ts | 217 +++++++++++++-------------- src/copilot/utils.ts | 187 +++++++++++++++++++++++ 3 files changed, 327 insertions(+), 115 deletions(-) diff --git a/src/copilot/context/copilotHelper.ts b/src/copilot/context/copilotHelper.ts index 4b322c12..5592887e 100644 --- a/src/copilot/context/copilotHelper.ts +++ b/src/copilot/context/copilotHelper.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -import { commands, Uri } from "vscode"; +import { commands, Uri, CancellationToken } from "vscode"; import { logger } from "../utils"; import { validateAndRecommendExtension } from "../../recommendation"; @@ -16,15 +16,45 @@ export namespace CopilotHelper { /** * Resolves all local project types imported by the given file * @param fileUri The URI of the Java file to analyze + * @param cancellationToken Optional cancellation token to abort the operation * @returns Array of strings in format "type:fully.qualified.name" where type is class|interface|enum|annotation */ - export async function resolveLocalImports(fileUri: Uri): Promise { + export async function resolveLocalImports(fileUri: Uri, cancellationToken?: CancellationToken): Promise { + if (cancellationToken?.isCancellationRequested) { + return []; + } + if (!await validateAndRecommendExtension("vscjava.vscode-java-dependency", "Project Manager for Java extension is recommended to provide additional Java project explorer features.", true)) { return []; } + + if (cancellationToken?.isCancellationRequested) { + return []; + } + try { - return await commands.executeCommand("java.execute.workspaceCommand", "java.project.getImportClassContent", fileUri.toString()) || []; - } catch (error) { + // Create a promise that can be cancelled + const commandPromise = commands.executeCommand("java.execute.workspaceCommand", "java.project.getImportClassContent", fileUri.toString()) as Promise; + + if (cancellationToken) { + const result = await Promise.race([ + commandPromise, + new Promise((_, reject) => { + cancellationToken.onCancellationRequested(() => { + reject(new Error('Operation cancelled')); + }); + }) + ]); + return result || []; + } else { + const result = await commandPromise; + return result || []; + } + } catch (error: any) { + if (error.message === 'Operation cancelled') { + logger.info('Resolve local imports cancelled'); + return []; + } logger.error("Error resolving copilot request:", error); return []; } diff --git a/src/copilot/contextProvider.ts b/src/copilot/contextProvider.ts index 433361b4..7eac19e7 100644 --- a/src/copilot/contextProvider.ts +++ b/src/copilot/contextProvider.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { - ContextProviderApiV1, ResolveRequest, SupportedContextItem, type ContextProvider, @@ -12,7 +11,16 @@ import * as vscode from 'vscode'; import { CopilotHelper } from './context/copilotHelper'; import { sendInfo } from "vscode-extension-telemetry-wrapper"; import { contextCache } from './context/contextCache'; -import { getProjectJavaVersion, logger } from './utils'; +import { + getProjectJavaVersion, + logger, + JavaContextProviderUtils, + CancellationError, + InternalCancellationError, + CopilotCancellationError, + ContextResolverFunction, + CopilotApi +} from './utils'; import { getExtensionName } from '../utils/extension'; export async function registerCopilotContextProviders( @@ -22,63 +30,26 @@ export async function registerCopilotContextProviders( contextCache.initialize(context); try { - const copilotClientApi = await getCopilotClientApi(); - const copilotChatApi = await getCopilotChatApi(); - if (!copilotClientApi || !copilotChatApi) { + const apis = await JavaContextProviderUtils.getCopilotApis(); + if (!apis.clientApi || !apis.chatApi) { logger.info('Failed to find compatible version of GitHub Copilot extension installed. Skip registration of Copilot context provider.'); return; } + // Register the Java completion context provider const provider: ContextProvider = { id: getExtensionName(), // use extension id as provider id for now selector: [{ language: "java" }], - resolver: { - resolve: async (request, token) => { - // Check if we have a cached result for the current active editor - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor && activeEditor.document.languageId === 'java') { - // Get current caret offset for position-sensitive caching - const currentCaretOffset = activeEditor.document.offsetAt(activeEditor.selection.active); - - // Try to get cached imports with position validation - const cachedImports = await contextCache.get(activeEditor.document.uri, currentCaretOffset); - if (cachedImports) { - logger.trace('Using cached imports, cache size:', cachedImports.length); - // Return cached result as context items - return cachedImports.map((cls: any) => ({ - uri: cls.uri, - value: cls.className, - importance: 70, - origin: 'request' as const - })); - } - } - - return await resolveJavaContext(request, token); - } - } + resolver: { resolve: createJavaContextResolver() } }; - let installCount = 0; - if (copilotClientApi) { - const disposable = await installContextProvider(copilotClientApi, provider); - if (disposable) { - context.subscriptions.push(disposable); - installCount++; - } - } - if (copilotChatApi) { - const disposable = await installContextProvider(copilotChatApi, provider); - if (disposable) { - context.subscriptions.push(disposable); - installCount++; - } - } + const installCount = await JavaContextProviderUtils.installContextProviderOnApis(apis, provider, context, installContextProvider); if (installCount === 0) { logger.info('Incompatible GitHub Copilot extension installed. Skip registration of Java context providers.'); return; } + logger.info('Registration of Java context provider for GitHub Copilot extension succeeded.'); sendInfo("", { "action": "registerCopilotContextProvider", @@ -92,10 +63,64 @@ export async function registerCopilotContextProviders( } } -async function resolveJavaContext(_request: ResolveRequest, _token: vscode.CancellationToken): Promise { +/** + * Create the Java context resolver function + */ +function createJavaContextResolver(): ContextResolverFunction { + return async (request: ResolveRequest, copilotCancel: vscode.CancellationToken): Promise => { + const resolveStartTime = performance.now(); + let logMessage = `Java Context Provider: resolve(${request.documentContext.uri}:${request.documentContext.offset}):`; + + try { + // Check for immediate cancellation + JavaContextProviderUtils.checkCancellation(copilotCancel); + + // Check if we have a cached result for the current active editor + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor && activeEditor.document.languageId === 'java') { + // Get current caret offset for position-sensitive caching + const currentCaretOffset = activeEditor.document.offsetAt(activeEditor.selection.active); + + // Try to get cached imports with position validation + const cachedImports = await contextCache.get(activeEditor.document.uri, currentCaretOffset); + if (cachedImports && !copilotCancel.isCancellationRequested) { + logger.trace('Using cached imports, cache size:', cachedImports.length); + logMessage += `(cached result with ${cachedImports.length} items)`; + // Return cached result as context items + return JavaContextProviderUtils.createContextItemsFromImports(cachedImports); + } + } + + return await resolveJavaContext(request, copilotCancel); + } catch (error: any) { + try { + JavaContextProviderUtils.handleError(error, 'Java context provider resolve', resolveStartTime, logMessage); + } catch (handledError) { + // Return empty array if error handling throws + return []; + } + // This should never be reached due to handleError throwing, but TypeScript requires it + return []; + } finally { + const duration = Math.round(performance.now() - resolveStartTime); + if (!logMessage.includes('cancellation')) { + logMessage += `(completed in ${duration}ms)`; + logger.info(logMessage); + } + } + }; +} + +async function resolveJavaContext(request: ResolveRequest, copilotCancel: vscode.CancellationToken): Promise { const items: SupportedContextItem[] = []; const start = performance.now(); + const documentUri = request.documentContext.uri; + const caretOffset = request.documentContext.offset; + try { + // Check for cancellation before starting + JavaContextProviderUtils.checkCancellation(copilotCancel); + // Get current document and position information const activeEditor = vscode.window.activeTextEditor; if (!activeEditor || activeEditor.document.languageId !== 'java') { @@ -104,31 +129,27 @@ async function resolveJavaContext(_request: ResolveRequest, _token: vscode.Cance const document = activeEditor.document; - // 1. Project basic information (High importance) + // Project basic information (High importance) const javaVersion = await getProjectJavaVersion(document); + + // Check for cancellation after potentially long operation + JavaContextProviderUtils.checkCancellation(copilotCancel); - items.push({ - name: 'java.version', - value: javaVersion, - importance: 90, - id: 'java-version', - origin: 'request' - }); - - items.push({ - name: 'java.file', - value: vscode.workspace.asRelativePath(document.uri), - importance: 80, - id: 'java-file-path', - origin: 'request' - }); + items.push(JavaContextProviderUtils.createJavaVersionItem(javaVersion)); // Try to get cached imports first let importClass = await contextCache.get(document.uri); if (!importClass) { + // Check for cancellation before expensive operation + JavaContextProviderUtils.checkCancellation(copilotCancel); + // If not cached, resolve and cache the result - const resolvedImports = await CopilotHelper.resolveLocalImports(document.uri); + const resolvedImports = await CopilotHelper.resolveLocalImports(document.uri, copilotCancel); logger.trace('Resolved imports count:', resolvedImports); + + // Check for cancellation after resolution + JavaContextProviderUtils.checkCancellation(copilotCancel); + if (resolvedImports) { // Get current caret offset for position-sensitive caching const currentCaretOffset = vscode.window.activeTextEditor?.document.offsetAt( @@ -143,60 +164,34 @@ async function resolveJavaContext(_request: ResolveRequest, _token: vscode.Cance logger.info('Using cached imports in resolveJavaContext, cache size:', importClass.length); } + // Check for cancellation before processing results + JavaContextProviderUtils.checkCancellation(copilotCancel); + if (importClass) { - for (const cls of importClass) { - items.push({ - uri: cls.uri, - value: cls.className, - importance: 70, - origin: 'request' - }); - } + // Process imports in batches to reduce cancellation check overhead + const contextItems = JavaContextProviderUtils.createContextItemsFromImports(importClass); + + // Check cancellation once after creating all items + JavaContextProviderUtils.checkCancellation(copilotCancel); + + items.push(...contextItems); + } + } catch (error: any) { + if (error instanceof CopilotCancellationError) { + throw error; + } + if (error instanceof vscode.CancellationError || error.message === CancellationError.Canceled) { + throw new InternalCancellationError(); } - } catch (error) { - logger.error('Error resolving Java context:', error); + logger.error(`Error resolving Java context for ${documentUri}:${caretOffset}:`, error); + // Don't rethrow general errors, return partial results } - logger.info('Total context resolution time:', performance.now() - start, 'ms', ' ,size:', items.length); - logger.info('Context items:', items); + + JavaContextProviderUtils.logCompletion('Java context resolution', documentUri, caretOffset, start, items.length); return items; } -interface CopilotApi { - getContextProviderAPI(version: string): Promise; -} - -async function getCopilotClientApi(): Promise { - const extension = vscode.extensions.getExtension('github.copilot'); - if (!extension) { - return undefined; - } - try { - return await extension.activate(); - } catch { - return undefined; - } -} - -async function getCopilotChatApi(): Promise { - type CopilotChatApi = { getAPI?(version: number): CopilotApi | undefined }; - const extension = vscode.extensions.getExtension('github.copilot-chat'); - if (!extension) { - return undefined; - } - - let exports: CopilotChatApi | undefined; - try { - exports = await extension.activate(); - } catch { - return undefined; - } - if (!exports || typeof exports.getAPI !== 'function') { - return undefined; - } - return exports.getAPI(1); -} - -async function installContextProvider( +export async function installContextProvider( copilotAPI: CopilotApi, contextProvider: ContextProvider ): Promise { diff --git a/src/copilot/utils.ts b/src/copilot/utils.ts index 8ad431db..534ea611 100644 --- a/src/copilot/utils.ts +++ b/src/copilot/utils.ts @@ -1,7 +1,14 @@ import { LogOutputChannel, SymbolKind, TextDocument, commands, window, Range, Selection, workspace, DocumentSymbol, version } from "vscode"; +import * as vscode from 'vscode'; import { SymbolNode } from "./inspect/SymbolNode"; import { SemVer } from "semver"; import { createUuid, sendOperationEnd, sendOperationError, sendOperationStart } from "vscode-extension-telemetry-wrapper"; +import { + ContextProviderApiV1, + ResolveRequest, + SupportedContextItem, + type ContextProvider, +} from '@github/copilot-language-server'; export const CLASS_KINDS: SymbolKind[] = [SymbolKind.Class, SymbolKind.Interface, SymbolKind.Enum]; export const METHOD_KINDS: SymbolKind[] = [SymbolKind.Method, SymbolKind.Constructor]; @@ -151,4 +158,184 @@ export function fixedInstrumentOperation( */ export function fixedInstrumentSimpleOperation(operationName: string, cb: (...args: any[]) => any, thisArg?: any): (...args: any[]) => any { return fixedInstrumentOperation(operationName, async (_operationId, ...args) => await cb.apply(thisArg, args), thisArg /** unnecessary */); +} + +/** + * Error classes for Copilot context provider cancellation handling + */ +export class CancellationError extends Error { + static readonly Canceled = "Canceled"; + constructor() { + super(CancellationError.Canceled); + this.name = this.message; + } +} + +export class InternalCancellationError extends CancellationError { +} + +export class CopilotCancellationError extends CancellationError { +} + +/** + * Type definitions for common patterns + */ +export type ContextResolverFunction = (request: ResolveRequest, token: vscode.CancellationToken) => Promise; + +export interface CopilotApiWrapper { + clientApi?: CopilotApi; + chatApi?: CopilotApi; +} + +export interface CopilotApi { + getContextProviderAPI(version: string): Promise; +} + +/** + * Utility class for handling common operations in Java Context Provider + */ +export class JavaContextProviderUtils { + /** + * Check if operation should be cancelled and throw appropriate error + */ + static checkCancellation(token: vscode.CancellationToken): void { + if (token.isCancellationRequested) { + throw new CopilotCancellationError(); + } + } + + /** + * Handle errors with appropriate logging and re-throwing + */ + static handleError(error: any, operation: string, startTime: number, logMessage: string): never { + const duration = Math.round(performance.now() - startTime); + + if (error instanceof CopilotCancellationError) { + const message = `${logMessage}(copilot cancellation after ${duration}ms)`; + logger.info(message); + throw error; + } + if (error instanceof InternalCancellationError) { + const message = `${logMessage}(internal cancellation after ${duration}ms)`; + logger.info(message); + throw error; + } + if (error instanceof vscode.CancellationError || error.message === CancellationError.Canceled) { + const message = `${logMessage}(cancellation after ${duration}ms)`; + logger.info(message); + throw new CancellationError(); + } + + logger.error(`Error in ${operation}:`, error); + throw error; + } + + /** + * Create context items from import classes + */ + static createContextItemsFromImports(importClasses: any[]): SupportedContextItem[] { + return importClasses.map((cls: any) => ({ + uri: cls.uri, + value: cls.className, + importance: 70, + origin: 'request' as const + })); + } + + /** + * Create a basic Java version context item + */ + static createJavaVersionItem(javaVersion: string): SupportedContextItem { + return { + name: 'java.version', + value: javaVersion, + importance: 90, + id: 'java-version', + origin: 'request' + }; + } + + /** + * Log completion with timing information + */ + static logCompletion(operation: string, documentUri: string, caretOffset: number, startTime: number, itemCount: number): void { + const duration = Math.round(performance.now() - startTime); + logger.info(`${operation} for ${documentUri}:${caretOffset} completed in ${duration}ms with ${itemCount} items`); + } + + /** + * Get and validate Copilot APIs + */ + static async getCopilotApis(): Promise { + const copilotClientApi = await getCopilotClientApi(); + const copilotChatApi = await getCopilotChatApi(); + return { clientApi: copilotClientApi, chatApi: copilotChatApi }; + } + + /** + * Install context provider on available APIs + */ + static async installContextProviderOnApis( + apis: CopilotApiWrapper, + provider: ContextProvider, + context: vscode.ExtensionContext, + installFn: (api: CopilotApi, provider: ContextProvider) => Promise + ): Promise { + let installCount = 0; + + if (apis.clientApi) { + const disposable = await installFn(apis.clientApi, provider); + if (disposable) { + context.subscriptions.push(disposable); + installCount++; + } + } + + if (apis.chatApi) { + const disposable = await installFn(apis.chatApi, provider); + if (disposable) { + context.subscriptions.push(disposable); + installCount++; + } + } + + return installCount; + } +} + +/** + * Get Copilot client API + */ +export async function getCopilotClientApi(): Promise { + const extension = vscode.extensions.getExtension('github.copilot'); + if (!extension) { + return undefined; + } + try { + return await extension.activate(); + } catch { + return undefined; + } +} + +/** + * Get Copilot chat API + */ +export async function getCopilotChatApi(): Promise { + type CopilotChatApi = { getAPI?(version: number): CopilotApi | undefined }; + const extension = vscode.extensions.getExtension('github.copilot-chat'); + if (!extension) { + return undefined; + } + + let exports: CopilotChatApi | undefined; + try { + exports = await extension.activate(); + } catch { + return undefined; + } + if (!exports || typeof exports.getAPI !== 'function') { + return undefined; + } + return exports.getAPI(1); } \ No newline at end of file From 465260a66c02f7184f98bbcba0566f509ec9de60 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 25 Sep 2025 09:59:30 +0800 Subject: [PATCH 08/13] fix: update the code logic according to comments --- src/copilot/context/contextCache.ts | 40 ++++++++++------------------ src/copilot/context/copilotHelper.ts | 6 ++--- src/recommendation/index.ts | 12 ++++++++- src/utils/index.ts | 4 +++ 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/copilot/context/contextCache.ts b/src/copilot/context/contextCache.ts index e2f7e1a9..f5933bf1 100644 --- a/src/copilot/context/contextCache.ts +++ b/src/copilot/context/contextCache.ts @@ -20,8 +20,8 @@ interface CacheEntry { documentVersion?: number; /** Last access timestamp */ lastAccess: number; - /** File content hash for change detection */ - contentHash?: string; + /** File content fingerprint for change detection */ + contentFingerprint?: string; /** Caret offset when cached (for position-sensitive invalidation) */ caretOffset?: number; } @@ -104,6 +104,8 @@ export class ContextCache { /** * Generate a hash for the document URI to use as cache key + * Note: We use MD5 for URI hashing because URIs can be long and contain special characters, + * while MD5 provides consistent, fixed-length keys that are safe for Map keys. * @param uri Document URI * @returns Hashed URI string */ @@ -191,17 +193,17 @@ export class ContextCache { this.evictLeastRecentlyUsed(); } - // Generate lightweight content hash if enabled - let contentHash: string | undefined; + // Generate lightweight content fingerprint if enabled + let contentFingerprint: string | undefined; if (this.enableContentHashing) { try { const document = await vscode.workspace.openTextDocument(uri); // Use document version and file stats for efficient change detection const stats = await vscode.workspace.fs.stat(uri); - const hashInput = `${document.version}-${stats.mtime}-${stats.size}`; - contentHash = crypto.createHash('md5').update(hashInput).digest('hex'); + // Use the fingerprint directly - it's short and efficient + contentFingerprint = `${document.version}-${stats.mtime}-${stats.size}`; } catch (error) { - logger.error('Failed to generate content hash:', error); + logger.error('Failed to generate content fingerprint:', error); } } @@ -211,7 +213,7 @@ export class ContextCache { timestamp: now, lastAccess: now, documentVersion, - contentHash, + contentFingerprint, caretOffset }); } @@ -288,13 +290,13 @@ export class ContextCache { } /** - * Check if content has changed by comparing lightweight hash + * Check if content has changed by comparing lightweight fingerprint * @param uri Document URI * @param entry Cache entry to check * @returns True if content has changed */ private async hasContentChanged(uri: vscode.Uri, entry: CacheEntry): Promise { - if (!this.enableContentHashing || !entry.contentHash) { + if (!this.enableContentHashing || !entry.contentFingerprint) { return false; } @@ -307,9 +309,8 @@ export class ContextCache { // If document version is the same or not available, check file stats const stats = await vscode.workspace.fs.stat(uri); - const hashInput = `${document.version}-${stats.mtime}-${stats.size}`; - const currentHash = crypto.createHash('md5').update(hashInput).digest('hex'); - return currentHash !== entry.contentHash; + const currentFingerprint = `${document.version}-${stats.mtime}-${stats.size}`; + return currentFingerprint !== entry.contentFingerprint; } catch (error) { logger.error('Failed to check content change:', error); return false; @@ -413,16 +414,3 @@ export class ContextCache { * Default context cache instance */ export const contextCache = new ContextCache(); - -/** - * Enhanced context cache instance with position-sensitive features enabled - * for more precise code completion context - */ -export const enhancedContextCache = new ContextCache({ - expiryTime: 10 * 60 * 1000, // 10 minutes - enablePositionSensitive: true, - maxCaretDistance: 8192, // Same as CopilotCompletionContextProvider - enableContentHashing: true, - maxCacheSize: 100, - cleanupInterval: 2 * 60 * 1000 // 2 minutes -}); diff --git a/src/copilot/context/copilotHelper.ts b/src/copilot/context/copilotHelper.ts index 5592887e..f3c2d8b7 100644 --- a/src/copilot/context/copilotHelper.ts +++ b/src/copilot/context/copilotHelper.ts @@ -3,7 +3,7 @@ import { commands, Uri, CancellationToken } from "vscode"; import { logger } from "../utils"; -import { validateAndRecommendExtension } from "../../recommendation"; +import { validateExtensionInstalled } from "../../recommendation"; export interface INodeImportClass { uri: string; @@ -23,8 +23,8 @@ export namespace CopilotHelper { if (cancellationToken?.isCancellationRequested) { return []; } - - if (!await validateAndRecommendExtension("vscjava.vscode-java-dependency", "Project Manager for Java extension is recommended to provide additional Java project explorer features.", true)) { + // Ensure the Java Dependency extension is installed and meets the minimum version requirement. + if (!await validateExtensionInstalled("vscjava.vscode-java-dependency", "0.26.0")) { return []; } diff --git a/src/recommendation/index.ts b/src/recommendation/index.ts index f749ed07..c5c16d9a 100644 --- a/src/recommendation/index.ts +++ b/src/recommendation/index.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode"; import { initialize as initHandler, extensionRecommendationHandler } from "./handler"; -import { isExtensionInstalled, getExtensionContext } from "../utils"; +import { isExtensionInstalled, getExtensionContext, getInstalledExtension } from "../utils"; export function initialize(_context: vscode.ExtensionContext) { initHandler(); @@ -18,3 +18,13 @@ export async function validateAndRecommendExtension(extName: string, message: st return false; } + +export async function validateExtensionInstalled(extName: string, version: string) { + if(isExtensionInstalled(extName)) { + return true; + } + if(version && getInstalledExtension(extName)?.packageJSON.version >= version) { + return true; + } + return false; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index fe520eee..eaac33a5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -27,6 +27,10 @@ export function isExtensionInstalled(extName: string) { return !!vscode.extensions.getExtension(extName); } +export function getInstalledExtension(extName: string) { + return vscode.extensions.getExtension(extName); +} + export async function recommendExtension(extName: string, message: string): Promise { const action = "Install"; const answer = await vscode.window.showInformationMessage(message, action); From 363c75441f1d745c8d7537fd2b54974f045935be Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 25 Sep 2025 10:36:23 +0800 Subject: [PATCH 09/13] fix: update code according to comments --- src/copilot/context/contextCache.ts | 57 +++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/src/copilot/context/contextCache.ts b/src/copilot/context/contextCache.ts index f5933bf1..c7f98d3b 100644 --- a/src/copilot/context/contextCache.ts +++ b/src/copilot/context/contextCache.ts @@ -46,6 +46,10 @@ interface ContextCacheOptions { maxCaretDistance?: number; /** Enable position-sensitive cache invalidation. Default: false */ enablePositionSensitive?: boolean; + /** Only watch files that are currently cached (more efficient). Default: true */ + watchOnlyCachedFiles?: boolean; + /** Custom directories to exclude from file watching. Default: common build/dependency folders */ + watcherExcludeDirs?: string[]; } /** @@ -61,6 +65,8 @@ export class ContextCache { private readonly cleanupIntervalMs: number; private readonly maxCaretDistance: number; private readonly enablePositionSensitive: boolean; + private readonly watchOnlyCachedFiles: boolean; + private readonly watcherExcludeDirs: string[]; private cleanupTimer?: NodeJS.Timeout; private fileWatcher?: vscode.FileSystemWatcher; @@ -75,6 +81,11 @@ export class ContextCache { this.cleanupIntervalMs = options.cleanupInterval ?? 2 * 60 * 1000; // 2 minutes this.maxCaretDistance = options.maxCaretDistance ?? 8192; // Same as CopilotCompletionContextProvider this.enablePositionSensitive = options.enablePositionSensitive ?? false; + this.watchOnlyCachedFiles = options.watchOnlyCachedFiles ?? true; + this.watcherExcludeDirs = options.watcherExcludeDirs ?? [ + 'node_modules', 'target', 'build', 'out', '.git', + 'bin', '.vscode', '.idea', 'dist', '.next', 'coverage' + ]; } /** @@ -380,12 +391,38 @@ export class ContextCache { /** * Setup file system watcher for Java files to invalidate cache on changes + * Optimized to exclude common directories that don't need monitoring */ private setupFileWatcher(): void { this.fileWatcher = vscode.workspace.createFileSystemWatcher('**/*.java'); + const shouldIgnoreFile = (uri: vscode.Uri): boolean => { + const path = uri.fsPath.toLowerCase(); + return this.watcherExcludeDirs.some(dir => + path.includes(`/${dir}/`) || path.includes(`\\${dir}\\`) || + path.includes(`/${dir}`) || path.includes(`\\${dir}`) + ); + }; + const invalidateHandler = (uri: vscode.Uri) => { - this.invalidate(uri); + // Skip files in excluded directories for performance + if (shouldIgnoreFile(uri)) { + return; + } + + // Apply smart filtering based on configuration + if (this.watchOnlyCachedFiles) { + // Only invalidate if we actually have this file cached (more efficient) + const key = this.generateCacheKey(uri); + if (this.cache.has(key)) { + this.invalidate(uri); + logger.trace('Cache invalidated due to file change:', uri.fsPath); + } + } else { + // Invalidate all files (less efficient but more comprehensive) + this.invalidate(uri); + logger.trace('Cache invalidated due to file change:', uri.fsPath); + } }; this.fileWatcher.onDidChange(invalidateHandler); @@ -411,6 +448,20 @@ export class ContextCache { } /** - * Default context cache instance + * Default context cache instance with performance optimizations + * Configured for mixed projects (Java + TypeScript/Node.js) */ -export const contextCache = new ContextCache(); +export const contextCache = new ContextCache({ + enableFileWatching: true, + watchOnlyCachedFiles: true, // Only watch files we actually cache (major performance improvement) + watcherExcludeDirs: [ + // Standard exclusions for mixed projects + 'node_modules', 'target', 'build', 'out', '.git', + 'bin', '.vscode', '.idea', 'dist', '.next', 'coverage', + // Additional exclusions for complex projects + 'logs', 'tmp', 'temp', '.cache', '.gradle' + ], + expiryTime: 10 * 60 * 1000, // 10 minutes + maxCacheSize: 100, + enableContentHashing: true +}); From d730437080ea5a24265388d29751c2d4c43a179f Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 25 Sep 2025 14:04:39 +0800 Subject: [PATCH 10/13] feat: remove cache design --- src/copilot/context/contextCache.ts | 467 ---------------------------- src/copilot/contextProvider.ts | 55 +--- 2 files changed, 9 insertions(+), 513 deletions(-) delete mode 100644 src/copilot/context/contextCache.ts diff --git a/src/copilot/context/contextCache.ts b/src/copilot/context/contextCache.ts deleted file mode 100644 index c7f98d3b..00000000 --- a/src/copilot/context/contextCache.ts +++ /dev/null @@ -1,467 +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 * as vscode from 'vscode'; -import * as crypto from 'crypto'; -import { INodeImportClass } from './copilotHelper'; -import { logger } from "../utils"; -/** - * Cache entry interface for storing import data with enhanced metadata - */ -interface CacheEntry { - /** Unique cache entry ID for tracking */ - id: string; - /** Cached import data */ - value: INodeImportClass[]; - /** Creation timestamp */ - timestamp: number; - /** Document version when cached */ - documentVersion?: number; - /** Last access timestamp */ - lastAccess: number; - /** File content fingerprint for change detection */ - contentFingerprint?: string; - /** Caret offset when cached (for position-sensitive invalidation) */ - caretOffset?: number; -} - -/** - * Configuration options for the context cache - */ -interface ContextCacheOptions { - /** Cache expiry time in milliseconds. Default: 10 minutes */ - expiryTime?: number; - /** Enable automatic cleanup interval. Default: true */ - enableAutoCleanup?: boolean; - /** Enable file watching for cache invalidation. Default: true */ - enableFileWatching?: boolean; - /** Maximum cache size (number of entries). Default: 100 */ - maxCacheSize?: number; - /** Enable content-based invalidation. Default: true */ - enableContentHashing?: boolean; - /** Cleanup interval in milliseconds. Default: 2 minutes */ - cleanupInterval?: number; - /** Maximum distance from cached caret position before cache becomes stale. Default: 8192 */ - maxCaretDistance?: number; - /** Enable position-sensitive cache invalidation. Default: false */ - enablePositionSensitive?: boolean; - /** Only watch files that are currently cached (more efficient). Default: true */ - watchOnlyCachedFiles?: boolean; - /** Custom directories to exclude from file watching. Default: common build/dependency folders */ - watcherExcludeDirs?: string[]; -} - -/** - * Context cache manager for storing and managing Java import contexts - */ -export class ContextCache { - private readonly cache = new Map(); - private readonly expiryTime: number; - private readonly enableAutoCleanup: boolean; - private readonly enableFileWatching: boolean; - private readonly maxCacheSize: number; - private readonly enableContentHashing: boolean; - private readonly cleanupIntervalMs: number; - private readonly maxCaretDistance: number; - private readonly enablePositionSensitive: boolean; - private readonly watchOnlyCachedFiles: boolean; - private readonly watcherExcludeDirs: string[]; - - private cleanupTimer?: NodeJS.Timeout; - private fileWatcher?: vscode.FileSystemWatcher; - private accessCount = 0; // For statistics tracking - - constructor(options: ContextCacheOptions = {}) { - this.expiryTime = options.expiryTime ?? 10 * 60 * 1000; // 10 minutes default - this.enableAutoCleanup = options.enableAutoCleanup ?? true; - this.enableFileWatching = options.enableFileWatching ?? true; - this.maxCacheSize = options.maxCacheSize ?? 100; - this.enableContentHashing = options.enableContentHashing ?? true; - this.cleanupIntervalMs = options.cleanupInterval ?? 2 * 60 * 1000; // 2 minutes - this.maxCaretDistance = options.maxCaretDistance ?? 8192; // Same as CopilotCompletionContextProvider - this.enablePositionSensitive = options.enablePositionSensitive ?? false; - this.watchOnlyCachedFiles = options.watchOnlyCachedFiles ?? true; - this.watcherExcludeDirs = options.watcherExcludeDirs ?? [ - 'node_modules', 'target', 'build', 'out', '.git', - 'bin', '.vscode', '.idea', 'dist', '.next', 'coverage' - ]; - } - - /** - * Initialize the cache with VS Code extension context - * @param context VS Code extension context for managing disposables - */ - public initialize(context: vscode.ExtensionContext): void { - if (this.enableAutoCleanup) { - this.startPeriodicCleanup(); - } - - if (this.enableFileWatching) { - this.setupFileWatcher(); - } - - // Register cleanup on extension disposal - context.subscriptions.push( - new vscode.Disposable(() => { - this.dispose(); - }) - ); - - if (this.fileWatcher) { - context.subscriptions.push(this.fileWatcher); - } - } - - /** - * Generate a hash for the document URI to use as cache key - * Note: We use MD5 for URI hashing because URIs can be long and contain special characters, - * while MD5 provides consistent, fixed-length keys that are safe for Map keys. - * @param uri Document URI - * @returns Hashed URI string - */ - private generateCacheKey(uri: vscode.Uri): string { - return crypto.createHash('md5').update(uri.toString()).digest('hex'); - } - - /** - * Get cached imports for a document URI with enhanced validation - * @param uri Document URI - * @param currentCaretOffset Optional current caret offset for position-sensitive validation - * @returns Cached imports or null if not found/expired/stale - */ - public async get(uri: vscode.Uri, currentCaretOffset?: number): Promise { - const key = this.generateCacheKey(uri); - const cached = this.cache.get(key); - - if (!cached) { - return null; - } - - // Check if cache is expired or stale - if (await this.isExpiredOrStale(uri, cached, currentCaretOffset)) { - this.cache.delete(key); - return null; - } - - // Update last access time and increment access count - cached.lastAccess = Date.now(); - this.accessCount++; - - return cached.value; - } - - /** - * Get cached imports synchronously (fallback method for compatibility) - * @param uri Document URI - * @param currentCaretOffset Optional current caret offset for position-sensitive validation - * @returns Cached imports or null if not found/expired - */ - public getSync(uri: vscode.Uri, currentCaretOffset?: number): INodeImportClass[] | null { - const key = this.generateCacheKey(uri); - const cached = this.cache.get(key); - - if (!cached) { - return null; - } - - // Check time-based expiry - if (this.isExpired(cached)) { - this.cache.delete(key); - return null; - } - - // Check position-sensitive expiry if enabled and caret offsets available - if (this.enablePositionSensitive && - cached.caretOffset !== undefined && - currentCaretOffset !== undefined) { - if (this.isStaleCacheHit(currentCaretOffset, cached.caretOffset)) { - this.cache.delete(key); - return null; - } - } - - // Update last access time and increment access count - cached.lastAccess = Date.now(); - this.accessCount++; - - return cached.value; - } - - /** - * Set cached imports for a document URI - * @param uri Document URI - * @param imports Import class array to cache - * @param documentVersion Optional document version - * @param caretOffset Optional caret offset for position-sensitive caching - */ - public async set(uri: vscode.Uri, imports: INodeImportClass[], documentVersion?: number, caretOffset?: number): Promise { - const key = this.generateCacheKey(uri); - const now = Date.now(); - - // Check cache size limit and evict if necessary - if (this.cache.size >= this.maxCacheSize) { - this.evictLeastRecentlyUsed(); - } - - // Generate lightweight content fingerprint if enabled - let contentFingerprint: string | undefined; - if (this.enableContentHashing) { - try { - const document = await vscode.workspace.openTextDocument(uri); - // Use document version and file stats for efficient change detection - const stats = await vscode.workspace.fs.stat(uri); - // Use the fingerprint directly - it's short and efficient - contentFingerprint = `${document.version}-${stats.mtime}-${stats.size}`; - } catch (error) { - logger.error('Failed to generate content fingerprint:', error); - } - } - - this.cache.set(key, { - id: crypto.randomUUID(), - value: imports, - timestamp: now, - lastAccess: now, - documentVersion, - contentFingerprint, - caretOffset - }); - } - - /** - * Check if a cache entry is expired - * @param entry Cache entry to check - * @returns True if expired, false otherwise - */ - private isExpired(entry: CacheEntry): boolean { - return Date.now() - entry.timestamp > this.expiryTime; - } - - /** - * Check if cache is stale based on caret position (similar to CopilotCompletionContextProvider) - * @param currentCaretOffset Current caret offset - * @param cachedCaretOffset Cached caret offset - * @returns True if stale, false otherwise - */ - private isStaleCacheHit(currentCaretOffset: number, cachedCaretOffset: number): boolean { - return Math.abs(currentCaretOffset - cachedCaretOffset) > this.maxCaretDistance; - } - - /** - * Enhanced expiry check including content changes and position sensitivity - * @param uri Document URI - * @param entry Cache entry to check - * @param currentCaretOffset Optional current caret offset - * @returns True if expired or stale - */ - private async isExpiredOrStale(uri: vscode.Uri, entry: CacheEntry, currentCaretOffset?: number): Promise { - // Check time-based expiry - if (this.isExpired(entry)) { - return true; - } - - // Check position-sensitive expiry if enabled and caret offsets available - if (this.enablePositionSensitive && - entry.caretOffset !== undefined && - currentCaretOffset !== undefined) { - if (this.isStaleCacheHit(currentCaretOffset, entry.caretOffset)) { - return true; - } - } - - // Check content-based changes - if (await this.hasContentChanged(uri, entry)) { - return true; - } - - return false; - } - - /** - * Evict least recently used cache entries when cache is full - */ - private evictLeastRecentlyUsed(): void { - if (this.cache.size === 0) return; - - let oldestTime = Date.now(); - let oldestKey = ''; - - for (const [key, entry] of this.cache.entries()) { - if (entry.lastAccess < oldestTime) { - oldestTime = entry.lastAccess; - oldestKey = key; - } - } - - if (oldestKey) { - this.cache.delete(oldestKey); - logger.trace('Evicted LRU cache entry:', oldestKey); - } - } - - /** - * Check if content has changed by comparing lightweight fingerprint - * @param uri Document URI - * @param entry Cache entry to check - * @returns True if content has changed - */ - private async hasContentChanged(uri: vscode.Uri, entry: CacheEntry): Promise { - if (!this.enableContentHashing || !entry.contentFingerprint) { - return false; - } - - try { - // Fast check using document version first - const document = await vscode.workspace.openTextDocument(uri); - if (entry.documentVersion !== undefined && document.version !== entry.documentVersion) { - return true; - } - - // If document version is the same or not available, check file stats - const stats = await vscode.workspace.fs.stat(uri); - const currentFingerprint = `${document.version}-${stats.mtime}-${stats.size}`; - return currentFingerprint !== entry.contentFingerprint; - } catch (error) { - logger.error('Failed to check content change:', error); - return false; - } - } - - /** - * Clear expired cache entries - */ - public clearExpired(): void { - const now = Date.now(); - for (const [key, entry] of this.cache.entries()) { - if (now - entry.timestamp > this.expiryTime) { - this.cache.delete(key); - } - } - } - - /** - * Clear all cache entries - */ - public clear(): void { - this.cache.clear(); - } - - /** - * Invalidate cache for specific URI - * @param uri URI to invalidate - */ - public invalidate(uri: vscode.Uri): void { - const key = this.generateCacheKey(uri); - if (this.cache.has(key)) { - this.cache.delete(key); - logger.trace('Cache invalidated for:', uri.toString()); - } - } - - /** - * Get cache statistics - * @returns Object containing cache size and other statistics - */ - public getStats(): { - size: number; - expiryTime: number; - accessCount: number; - maxSize: number; - hitRate?: number; - positionSensitive: boolean; - } { - return { - size: this.cache.size, - expiryTime: this.expiryTime, - accessCount: this.accessCount, - maxSize: this.maxCacheSize, - positionSensitive: this.enablePositionSensitive - }; - } - - /** - * Start periodic cleanup of expired cache entries - */ - private startPeriodicCleanup(): void { - this.cleanupTimer = setInterval(() => { - this.clearExpired(); - }, this.cleanupIntervalMs); - } - - /** - * Setup file system watcher for Java files to invalidate cache on changes - * Optimized to exclude common directories that don't need monitoring - */ - private setupFileWatcher(): void { - this.fileWatcher = vscode.workspace.createFileSystemWatcher('**/*.java'); - - const shouldIgnoreFile = (uri: vscode.Uri): boolean => { - const path = uri.fsPath.toLowerCase(); - return this.watcherExcludeDirs.some(dir => - path.includes(`/${dir}/`) || path.includes(`\\${dir}\\`) || - path.includes(`/${dir}`) || path.includes(`\\${dir}`) - ); - }; - - const invalidateHandler = (uri: vscode.Uri) => { - // Skip files in excluded directories for performance - if (shouldIgnoreFile(uri)) { - return; - } - - // Apply smart filtering based on configuration - if (this.watchOnlyCachedFiles) { - // Only invalidate if we actually have this file cached (more efficient) - const key = this.generateCacheKey(uri); - if (this.cache.has(key)) { - this.invalidate(uri); - logger.trace('Cache invalidated due to file change:', uri.fsPath); - } - } else { - // Invalidate all files (less efficient but more comprehensive) - this.invalidate(uri); - logger.trace('Cache invalidated due to file change:', uri.fsPath); - } - }; - - this.fileWatcher.onDidChange(invalidateHandler); - this.fileWatcher.onDidDelete(invalidateHandler); - } - - /** - * Dispose of all resources (intervals, watchers, etc.) - */ - public dispose(): void { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - this.cleanupTimer = undefined; - } - - if (this.fileWatcher) { - this.fileWatcher.dispose(); - this.fileWatcher = undefined; - } - - this.clear(); - } -} - -/** - * Default context cache instance with performance optimizations - * Configured for mixed projects (Java + TypeScript/Node.js) - */ -export const contextCache = new ContextCache({ - enableFileWatching: true, - watchOnlyCachedFiles: true, // Only watch files we actually cache (major performance improvement) - watcherExcludeDirs: [ - // Standard exclusions for mixed projects - 'node_modules', 'target', 'build', 'out', '.git', - 'bin', '.vscode', '.idea', 'dist', '.next', 'coverage', - // Additional exclusions for complex projects - 'logs', 'tmp', 'temp', '.cache', '.gradle' - ], - expiryTime: 10 * 60 * 1000, // 10 minutes - maxCacheSize: 100, - enableContentHashing: true -}); diff --git a/src/copilot/contextProvider.ts b/src/copilot/contextProvider.ts index 7eac19e7..968aa18b 100644 --- a/src/copilot/contextProvider.ts +++ b/src/copilot/contextProvider.ts @@ -10,7 +10,6 @@ import { import * as vscode from 'vscode'; import { CopilotHelper } from './context/copilotHelper'; import { sendInfo } from "vscode-extension-telemetry-wrapper"; -import { contextCache } from './context/contextCache'; import { getProjectJavaVersion, logger, @@ -26,9 +25,6 @@ import { getExtensionName } from '../utils/extension'; export async function registerCopilotContextProviders( context: vscode.ExtensionContext ) { - // Initialize the context cache with enhanced options - contextCache.initialize(context); - try { const apis = await JavaContextProviderUtils.getCopilotApis(); if (!apis.clientApi || !apis.chatApi) { @@ -75,22 +71,6 @@ function createJavaContextResolver(): ContextResolverFunction { // Check for immediate cancellation JavaContextProviderUtils.checkCancellation(copilotCancel); - // Check if we have a cached result for the current active editor - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor && activeEditor.document.languageId === 'java') { - // Get current caret offset for position-sensitive caching - const currentCaretOffset = activeEditor.document.offsetAt(activeEditor.selection.active); - - // Try to get cached imports with position validation - const cachedImports = await contextCache.get(activeEditor.document.uri, currentCaretOffset); - if (cachedImports && !copilotCancel.isCancellationRequested) { - logger.trace('Using cached imports, cache size:', cachedImports.length); - logMessage += `(cached result with ${cachedImports.length} items)`; - // Return cached result as context items - return JavaContextProviderUtils.createContextItemsFromImports(cachedImports); - } - } - return await resolveJavaContext(request, copilotCancel); } catch (error: any) { try { @@ -137,32 +117,15 @@ async function resolveJavaContext(request: ResolveRequest, copilotCancel: vscode items.push(JavaContextProviderUtils.createJavaVersionItem(javaVersion)); - // Try to get cached imports first - let importClass = await contextCache.get(document.uri); - if (!importClass) { - // Check for cancellation before expensive operation - JavaContextProviderUtils.checkCancellation(copilotCancel); - - // If not cached, resolve and cache the result - const resolvedImports = await CopilotHelper.resolveLocalImports(document.uri, copilotCancel); - logger.trace('Resolved imports count:', resolvedImports); - - // Check for cancellation after resolution - JavaContextProviderUtils.checkCancellation(copilotCancel); - - if (resolvedImports) { - // Get current caret offset for position-sensitive caching - const currentCaretOffset = vscode.window.activeTextEditor?.document.offsetAt( - vscode.window.activeTextEditor.selection.active - ); - - await contextCache.set(document.uri, resolvedImports, undefined, currentCaretOffset); - importClass = resolvedImports; - logger.trace('Cached new imports, cache size:', importClass.length); - } - } else { - logger.info('Using cached imports in resolveJavaContext, cache size:', importClass.length); - } + // Check for cancellation before expensive operation + JavaContextProviderUtils.checkCancellation(copilotCancel); + + // Resolve imports directly without caching + const importClass = await CopilotHelper.resolveLocalImports(document.uri, copilotCancel); + logger.trace('Resolved imports count:', importClass?.length || 0); + + // Check for cancellation after resolution + JavaContextProviderUtils.checkCancellation(copilotCancel); // Check for cancellation before processing results JavaContextProviderUtils.checkCancellation(copilotCancel); From 1ba97886989abd016e18d475fe13a1d45dc137e3 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Fri, 26 Sep 2025 09:25:46 +0800 Subject: [PATCH 11/13] feat: add telemetry data report --- src/copilot/contextProvider.ts | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/copilot/contextProvider.ts b/src/copilot/contextProvider.ts index 968aa18b..f702fef8 100644 --- a/src/copilot/contextProvider.ts +++ b/src/copilot/contextProvider.ts @@ -91,6 +91,26 @@ function createJavaContextResolver(): ContextResolverFunction { }; } +/** + * Send telemetry data for Java context resolution + */ +function sendContextTelemetry(request: ResolveRequest, start: number, itemCount: number, status: string, error?: string) { + const duration = Math.round(performance.now() - start); + const telemetryData: any = { + "action": "resolveJavaContext", + "completionId": request.completionId, + "duration": duration, + "itemCount": itemCount, + "status": status + }; + + if (error) { + telemetryData.error = error; + } + + sendInfo("", telemetryData); +} + async function resolveJavaContext(request: ResolveRequest, copilotCancel: vscode.CancellationToken): Promise { const items: SupportedContextItem[] = []; const start = performance.now(); @@ -141,15 +161,27 @@ async function resolveJavaContext(request: ResolveRequest, copilotCancel: vscode } } catch (error: any) { if (error instanceof CopilotCancellationError) { + sendContextTelemetry(request, start, items.length, "cancelled_by_copilot"); throw error; } if (error instanceof vscode.CancellationError || error.message === CancellationError.Canceled) { + sendContextTelemetry(request, start, items.length, "cancelled_internally"); throw new InternalCancellationError(); } + + // Send telemetry for general errors (but continue with partial results) + sendContextTelemetry(request, start, items.length, "error_partial_results", error.message || "unknown_error"); + logger.error(`Error resolving Java context for ${documentUri}:${caretOffset}:`, error); - // Don't rethrow general errors, return partial results + + // Return partial results and log completion for error case + JavaContextProviderUtils.logCompletion('Java context resolution', documentUri, caretOffset, start, items.length); + return items; } + // Send telemetry data once at the end for success case + sendContextTelemetry(request, start, items.length, "succeeded"); + JavaContextProviderUtils.logCompletion('Java context resolution', documentUri, caretOffset, start, items.length); return items; } From ada5f1704dc5720a780f5f3f37da815eb2f143de Mon Sep 17 00:00:00 2001 From: wenyutang Date: Fri, 26 Sep 2025 11:58:26 +0800 Subject: [PATCH 12/13] feat: remove get java version --- src/copilot/contextProvider.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/copilot/contextProvider.ts b/src/copilot/contextProvider.ts index f702fef8..5f0e0144 100644 --- a/src/copilot/contextProvider.ts +++ b/src/copilot/contextProvider.ts @@ -10,8 +10,7 @@ import { import * as vscode from 'vscode'; import { CopilotHelper } from './context/copilotHelper'; import { sendInfo } from "vscode-extension-telemetry-wrapper"; -import { - getProjectJavaVersion, +import { logger, JavaContextProviderUtils, CancellationError, @@ -129,17 +128,6 @@ async function resolveJavaContext(request: ResolveRequest, copilotCancel: vscode const document = activeEditor.document; - // Project basic information (High importance) - const javaVersion = await getProjectJavaVersion(document); - - // Check for cancellation after potentially long operation - JavaContextProviderUtils.checkCancellation(copilotCancel); - - items.push(JavaContextProviderUtils.createJavaVersionItem(javaVersion)); - - // Check for cancellation before expensive operation - JavaContextProviderUtils.checkCancellation(copilotCancel); - // Resolve imports directly without caching const importClass = await CopilotHelper.resolveLocalImports(document.uri, copilotCancel); logger.trace('Resolved imports count:', importClass?.length || 0); From 718abf520640542253540bf7b5242a8eab8cbd10 Mon Sep 17 00:00:00 2001 From: wenyutang Date: Fri, 26 Sep 2025 12:23:44 +0800 Subject: [PATCH 13/13] fix: update --- src/recommendation/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/recommendation/index.ts b/src/recommendation/index.ts index c5c16d9a..fe0cf31a 100644 --- a/src/recommendation/index.ts +++ b/src/recommendation/index.ts @@ -20,8 +20,8 @@ export async function validateAndRecommendExtension(extName: string, message: st } export async function validateExtensionInstalled(extName: string, version: string) { - if(isExtensionInstalled(extName)) { - return true; + if(!isExtensionInstalled(extName)) { + return false; } if(version && getInstalledExtension(extName)?.packageJSON.version >= version) { return true;