From b49410e1d0d1fc4d5385812a75504a22090cb603 Mon Sep 17 00:00:00 2001 From: shishu314 Date: Wed, 3 Sep 2025 21:44:52 -0400 Subject: [PATCH] feat(extension) - Notify users when there is a new version and update it (#7408) Co-authored-by: Shi Shu Co-authored-by: Shreya --- .../src/extension.test.ts | 159 +++++++++++++++++- .../vscode-ide-companion/src/extension.ts | 70 ++++++++ 2 files changed, 223 insertions(+), 6 deletions(-) diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts index 2223050bcf..8377c01e97 100644 --- a/packages/vscode-ide-companion/src/extension.test.ts +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -50,12 +50,18 @@ vi.mock('vscode', () => ({ fire: vi.fn(), dispose: vi.fn(), })), + extensions: { + getExtension: vi.fn(), + }, })); describe('activate', () => { let context: vscode.ExtensionContext; beforeEach(() => { + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( + undefined, + ); context = { subscriptions: [], environmentVariableCollection: { @@ -68,6 +74,11 @@ describe('activate', () => { extensionUri: { fsPath: '/path/to/extension', }, + extension: { + packageJSON: { + version: '1.1.0', + }, + }, } as unknown as vscode.ExtensionContext; }); @@ -80,6 +91,9 @@ describe('activate', () => { .mocked(vscode.window.showInformationMessage) .mockResolvedValue(undefined as never); vi.mocked(context.globalState.get).mockReturnValue(undefined); + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + packageJSON: { version: '1.1.0' }, + } as vscode.Extension); await activate(context); expect(showInformationMessageMock).toHaveBeenCalledWith( 'Gemini CLI Companion extension successfully installed.', @@ -88,6 +102,9 @@ describe('activate', () => { it('should not show the info message on subsequent activations', async () => { vi.mocked(context.globalState.get).mockReturnValue(true); + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + packageJSON: { version: '1.1.0' }, + } as vscode.Extension); await activate(context); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); }); @@ -102,13 +119,143 @@ describe('activate', () => { .mocked(vscode.window.showInformationMessage) .mockResolvedValue('Re-launch Gemini CLI' as never); vi.mocked(context.globalState.get).mockReturnValue(undefined); + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + packageJSON: { version: '1.1.0' }, + } as vscode.Extension); await activate(context); - expect(showInformationMessageMock).toHaveBeenCalled(); - await new Promise(process.nextTick); // Wait for the promise to resolve - const commandCallback = vi - .mocked(vscode.commands.registerCommand) - .mock.calls.find((call) => call[0] === 'gemini-cli.runGeminiCLI')?.[1]; + expect(showInformationMessageMock).toHaveBeenCalledWith( + 'Gemini CLI Companion extension successfully installed.', + ); + }); - expect(commandCallback).toBeDefined(); + describe('update notification', () => { + beforeEach(() => { + // Prevent the "installed" message from showing + vi.mocked(context.globalState.get).mockReturnValue(true); + }); + + it('should show an update notification if a newer version is available', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + extensions: [ + { + versions: [{ version: '1.2.0' }], + }, + ], + }, + ], + }), + } as Response); + + const showInformationMessageMock = vi.mocked( + vscode.window.showInformationMessage, + ); + + await activate(context); + + expect(showInformationMessageMock).toHaveBeenCalledWith( + 'A new version (1.2.0) of the Gemini CLI Companion extension is available.', + 'Update to latest version', + ); + }); + + it('should not show an update notification if the version is the same', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + extensions: [ + { + versions: [{ version: '1.1.0' }], + }, + ], + }, + ], + }), + } as Response); + + const showInformationMessageMock = vi.mocked( + vscode.window.showInformationMessage, + ); + + await activate(context); + + expect(showInformationMessageMock).not.toHaveBeenCalled(); + }); + + it('should not show an update notification if the version is older', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + extensions: [ + { + versions: [{ version: '1.0.0' }], + }, + ], + }, + ], + }), + } as Response); + + const showInformationMessageMock = vi.mocked( + vscode.window.showInformationMessage, + ); + + await activate(context); + + expect(showInformationMessageMock).not.toHaveBeenCalled(); + }); + + it('should execute the install command when the user clicks "Update"', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + extensions: [ + { + versions: [{ version: '1.2.0' }], + }, + ], + }, + ], + }), + } as Response); + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( + 'Update to latest version' as never, + ); + const executeCommandMock = vi.mocked(vscode.commands.executeCommand); + + await activate(context); + + // Wait for the promise from showInformationMessage.then() to resolve + await new Promise(process.nextTick); + + expect(executeCommandMock).toHaveBeenCalledWith( + 'workbench.extensions.installExtension', + 'Google.gemini-cli-vscode-ide-companion', + ); + }); + + it('should handle fetch errors gracefully', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + statusText: 'Internal Server Error', + } as Response); + + const showInformationMessageMock = vi.mocked( + vscode.window.showInformationMessage, + ); + + await activate(context); + + expect(showInformationMessageMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 36fc4029d1..66020cb94f 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -6,9 +6,11 @@ import * as vscode from 'vscode'; import { IDEServer } from './ide-server.js'; +import semver from 'semver'; import { DiffContentProvider, DiffManager } from './diff-manager.js'; import { createLogger } from './utils/logger.js'; +const CLI_IDE_COMPANION_IDENTIFIER = 'Google.gemini-cli-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'geminiCliInfoMessageShown'; export const DIFF_SCHEME = 'gemini-diff'; @@ -17,11 +19,79 @@ let logger: vscode.OutputChannel; let log: (message: string) => void = () => {}; +async function checkForUpdates( + context: vscode.ExtensionContext, + log: (message: string) => void, +) { + try { + const currentVersion = context.extension.packageJSON.version; + + // Fetch extension details from the VSCode Marketplace. + const response = await fetch( + 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json;api-version=7.1-preview.1', + }, + body: JSON.stringify({ + filters: [ + { + criteria: [ + { + filterType: 7, // Corresponds to ExtensionName + value: CLI_IDE_COMPANION_IDENTIFIER, + }, + ], + }, + ], + // See: https://learn.microsoft.com/en-us/azure/devops/extend/gallery/apis/hyper-linking?view=azure-devops + // 946 = IncludeVersions | IncludeFiles | IncludeCategoryAndTags | + // IncludeShortDescription | IncludePublisher | IncludeStatistics + flags: 946, + }), + }, + ); + + if (!response.ok) { + log( + `Failed to fetch latest version info from marketplace: ${response.statusText}`, + ); + return; + } + + const data = await response.json(); + const extension = data?.results?.[0]?.extensions?.[0]; + // The versions are sorted by date, so the first one is the latest. + const latestVersion = extension?.versions?.[0]?.version; + + if (latestVersion && semver.gt(latestVersion, currentVersion)) { + const selection = await vscode.window.showInformationMessage( + `A new version (${latestVersion}) of the Gemini CLI Companion extension is available.`, + 'Update to latest version', + ); + if (selection === 'Update to latest version') { + // The install command will update the extension if a newer version is found. + await vscode.commands.executeCommand( + 'workbench.extensions.installExtension', + CLI_IDE_COMPANION_IDENTIFIER, + ); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`Error checking for extension updates: ${message}`); + } +} + export async function activate(context: vscode.ExtensionContext) { logger = vscode.window.createOutputChannel('Gemini CLI IDE Companion'); log = createLogger(context, logger); log('Extension activated'); + checkForUpdates(context, log); + const diffContentProvider = new DiffContentProvider(); const diffManager = new DiffManager(log, diffContentProvider);