/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { IdeDiffAcceptedNotificationSchema, IdeDiffClosedNotificationSchema, } from '@google/gemini-cli-core'; import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js'; import * as path from 'node:path'; import * as vscode from 'vscode'; import { DIFF_SCHEME } from './extension.js'; export class DiffContentProvider implements vscode.TextDocumentContentProvider { private content = new Map(); private onDidChangeEmitter = new vscode.EventEmitter(); get onDidChange(): vscode.Event { return this.onDidChangeEmitter.event; } provideTextDocumentContent(uri: vscode.Uri): string { return this.content.get(uri.toString()) ?? ''; } setContent(uri: vscode.Uri, content: string): void { this.content.set(uri.toString(), content); this.onDidChangeEmitter.fire(uri); } deleteContent(uri: vscode.Uri): void { this.content.delete(uri.toString()); } getContent(uri: vscode.Uri): string | undefined { return this.content.get(uri.toString()); } } // Information about a diff view that is currently open. interface DiffInfo { originalFilePath: string; newContent: string; rightDocUri: vscode.Uri; } /** * Manages the state and lifecycle of diff views within the IDE. */ export class DiffManager { private readonly onDidChangeEmitter = new vscode.EventEmitter(); readonly onDidChange = this.onDidChangeEmitter.event; private diffDocuments = new Map(); private readonly subscriptions: vscode.Disposable[] = []; constructor( private readonly log: (message: string) => void, private readonly diffContentProvider: DiffContentProvider, ) { this.subscriptions.push( vscode.window.onDidChangeActiveTextEditor((editor) => { this.onActiveEditorChange(editor); }), ); this.onActiveEditorChange(vscode.window.activeTextEditor); } dispose() { for (const subscription of this.subscriptions) { subscription.dispose(); } } /** * Creates and shows a new diff view. */ async showDiff(filePath: string, newContent: string) { const fileUri = vscode.Uri.file(filePath); const rightDocUri = vscode.Uri.from({ scheme: DIFF_SCHEME, path: filePath, // cache busting query: `rand=${Math.random()}`, }); this.diffContentProvider.setContent(rightDocUri, newContent); this.addDiffDocument(rightDocUri, { originalFilePath: filePath, newContent, rightDocUri, }); const diffTitle = `${path.basename(filePath)} ↔ Modified`; await vscode.commands.executeCommand( 'setContext', 'gemini.diff.isVisible', true, ); let leftDocUri; try { await vscode.workspace.fs.stat(fileUri); leftDocUri = fileUri; } catch { // We need to provide an empty document to diff against. // Using the 'untitled' scheme is one way to do this. leftDocUri = vscode.Uri.from({ scheme: 'untitled', path: filePath, }); } await vscode.commands.executeCommand( 'vscode.diff', leftDocUri, rightDocUri, diffTitle, { preview: false, preserveFocus: true, }, ); await vscode.commands.executeCommand( 'workbench.action.files.setActiveEditorWriteableInSession', ); } /** * Closes an open diff view for a specific file. */ async closeDiff(filePath: string) { let uriToClose: vscode.Uri | undefined; for (const [uriString, diffInfo] of this.diffDocuments.entries()) { if (diffInfo.originalFilePath === filePath) { uriToClose = vscode.Uri.parse(uriString); break; } } if (uriToClose) { const rightDoc = await vscode.workspace.openTextDocument(uriToClose); const modifiedContent = rightDoc.getText(); await this.closeDiffEditor(uriToClose); this.onDidChangeEmitter.fire( IdeDiffClosedNotificationSchema.parse({ jsonrpc: '2.0', method: 'ide/diffClosed', params: { filePath, content: modifiedContent, }, }), ); return modifiedContent; } return; } /** * User accepts the changes in a diff view. Does not apply changes. */ async acceptDiff(rightDocUri: vscode.Uri) { const diffInfo = this.diffDocuments.get(rightDocUri.toString()); if (!diffInfo) { this.log(`No diff info found for ${rightDocUri.toString()}`); return; } const rightDoc = await vscode.workspace.openTextDocument(rightDocUri); const modifiedContent = rightDoc.getText(); await this.closeDiffEditor(rightDocUri); this.onDidChangeEmitter.fire( IdeDiffAcceptedNotificationSchema.parse({ jsonrpc: '2.0', method: 'ide/diffAccepted', params: { filePath: diffInfo.originalFilePath, content: modifiedContent, }, }), ); } /** * Called when a user cancels a diff view. */ async cancelDiff(rightDocUri: vscode.Uri) { const diffInfo = this.diffDocuments.get(rightDocUri.toString()); if (!diffInfo) { this.log(`No diff info found for ${rightDocUri.toString()}`); // Even if we don't have diff info, we should still close the editor. await this.closeDiffEditor(rightDocUri); return; } const rightDoc = await vscode.workspace.openTextDocument(rightDocUri); const modifiedContent = rightDoc.getText(); await this.closeDiffEditor(rightDocUri); this.onDidChangeEmitter.fire( IdeDiffClosedNotificationSchema.parse({ jsonrpc: '2.0', method: 'ide/diffClosed', params: { filePath: diffInfo.originalFilePath, content: modifiedContent, }, }), ); } private async onActiveEditorChange(editor: vscode.TextEditor | undefined) { let isVisible = false; if (editor) { isVisible = this.diffDocuments.has(editor.document.uri.toString()); if (!isVisible) { for (const document of this.diffDocuments.values()) { if (document.originalFilePath === editor.document.uri.fsPath) { isVisible = true; break; } } } } await vscode.commands.executeCommand( 'setContext', 'gemini.diff.isVisible', isVisible, ); } private addDiffDocument(uri: vscode.Uri, diffInfo: DiffInfo) { this.diffDocuments.set(uri.toString(), diffInfo); } private async closeDiffEditor(rightDocUri: vscode.Uri) { const diffInfo = this.diffDocuments.get(rightDocUri.toString()); await vscode.commands.executeCommand( 'setContext', 'gemini.diff.isVisible', false, ); if (diffInfo) { this.diffDocuments.delete(rightDocUri.toString()); this.diffContentProvider.deleteContent(rightDocUri); } // Find and close the tab corresponding to the diff view for (const tabGroup of vscode.window.tabGroups.all) { for (const tab of tabGroup.tabs) { const input = tab.input as { modified?: vscode.Uri; original?: vscode.Uri; }; if (input && input.modified?.toString() === rightDocUri.toString()) { await vscode.window.tabGroups.close(tab); return; } } } } }