mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
feat(ide): Check for IDE diffing capabilities before opening diffs (#8266)
This commit is contained in:
@@ -83,6 +83,7 @@ describe('IdeClient', () => {
|
||||
close: vi.fn(),
|
||||
setNotificationHandler: vi.fn(),
|
||||
callTool: vi.fn(),
|
||||
request: vi.fn(),
|
||||
} as unknown as Mocked<Client>;
|
||||
mockHttpTransport = {
|
||||
close: vi.fn(),
|
||||
@@ -534,4 +535,93 @@ describe('IdeClient', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDiffingEnabled', () => {
|
||||
it('should return false if not connected', async () => {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
expect(ideClient.isDiffingEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if tool discovery fails', async () => {
|
||||
const config = { port: '8080' };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
mockClient.request.mockRejectedValue(new Error('Method not found'));
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
expect(ideClient.isDiffingEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if diffing tools are not available', async () => {
|
||||
const config = { port: '8080' };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
mockClient.request.mockResolvedValue({
|
||||
tools: [{ name: 'someOtherTool' }],
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
expect(ideClient.isDiffingEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if only openDiff tool is available', async () => {
|
||||
const config = { port: '8080' };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
mockClient.request.mockResolvedValue({
|
||||
tools: [{ name: 'openDiff' }],
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
expect(ideClient.isDiffingEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if connected and diffing tools are available', async () => {
|
||||
const config = { port: '8080' };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
mockClient.request.mockResolvedValue({
|
||||
tools: [{ name: 'openDiff' }, { name: 'closeDiff' }],
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
expect(ideClient.isDiffingEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EnvHttpProxyAgent } from 'undici';
|
||||
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const logger = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -78,6 +79,7 @@ export class IdeClient {
|
||||
private diffResponses = new Map<string, (result: DiffUpdateResult) => void>();
|
||||
private statusListeners = new Set<(state: IDEConnectionState) => void>();
|
||||
private trustChangeListeners = new Set<(isTrusted: boolean) => void>();
|
||||
private availableTools: string[] = [];
|
||||
/**
|
||||
* A mutex to ensure that only one diff view is open in the IDE at a time.
|
||||
* This prevents race conditions and UI issues in IDEs like VSCode that
|
||||
@@ -334,6 +336,53 @@ export class IdeClient {
|
||||
return this.currentIdeDisplayName;
|
||||
}
|
||||
|
||||
isDiffingEnabled(): boolean {
|
||||
return (
|
||||
!!this.client &&
|
||||
this.state.status === IDEConnectionStatus.Connected &&
|
||||
this.availableTools.includes('openDiff') &&
|
||||
this.availableTools.includes('closeDiff')
|
||||
);
|
||||
}
|
||||
|
||||
private async discoverTools(): Promise<void> {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
logger.debug('Discovering tools from IDE...');
|
||||
const response = await this.client.request(
|
||||
{ method: 'tools/list', params: {} },
|
||||
ListToolsResultSchema,
|
||||
);
|
||||
|
||||
// Map the array of tool objects to an array of tool names (strings)
|
||||
this.availableTools = response.tools.map((tool) => tool.name);
|
||||
|
||||
if (this.availableTools.length > 0) {
|
||||
logger.debug(
|
||||
`Discovered ${this.availableTools.length} tools from IDE: ${this.availableTools.join(', ')}`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
'IDE supports tool discovery, but no tools are available.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// It's okay if this fails, the IDE might not support it.
|
||||
// Don't log an error if the method is not found, which is a common case.
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!error.message?.includes('Method not found')
|
||||
) {
|
||||
logger.error(`Error discovering tools from IDE: ${error.message}`);
|
||||
} else {
|
||||
logger.debug('IDE does not support tool discovery.');
|
||||
}
|
||||
this.availableTools = [];
|
||||
}
|
||||
}
|
||||
|
||||
private setState(
|
||||
status: IDEConnectionStatus,
|
||||
details?: string,
|
||||
@@ -631,6 +680,7 @@ export class IdeClient {
|
||||
);
|
||||
await this.client.connect(transport);
|
||||
this.registerClientHandlers();
|
||||
await this.discoverTools();
|
||||
this.setState(IDEConnectionStatus.Connected);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
@@ -664,6 +714,7 @@ export class IdeClient {
|
||||
});
|
||||
await this.client.connect(transport);
|
||||
this.registerClientHandlers();
|
||||
await this.discoverTools();
|
||||
this.setState(IDEConnectionStatus.Connected);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
|
||||
@@ -10,16 +10,12 @@ const mockEnsureCorrectEdit = vi.hoisted(() => vi.fn());
|
||||
const mockGenerateJson = vi.hoisted(() => vi.fn());
|
||||
const mockOpenDiff = vi.hoisted(() => vi.fn());
|
||||
|
||||
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
|
||||
vi.mock('../ide/ide-client.js', () => ({
|
||||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
IDEConnectionStatus: {
|
||||
Connected: 'connected',
|
||||
Disconnected: 'disconnected',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/editCorrector.js', () => ({
|
||||
@@ -896,9 +892,7 @@ describe('EditTool', () => {
|
||||
filePath = path.join(rootDir, testFile);
|
||||
ideClient = {
|
||||
openDiff: vi.fn(),
|
||||
getConnectionStatus: vi.fn().mockReturnValue({
|
||||
status: IDEConnectionStatus.Connected,
|
||||
}),
|
||||
isDiffingEnabled: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue(ideClient);
|
||||
(mockConfig as any).getIdeMode = () => true;
|
||||
|
||||
@@ -32,7 +32,7 @@ import type {
|
||||
ModifiableDeclarativeTool,
|
||||
ModifyContext,
|
||||
} from './modifiable-tool.js';
|
||||
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
|
||||
export function applyReplacement(
|
||||
currentContent: string | null,
|
||||
@@ -268,8 +268,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
);
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const ideConfirmation =
|
||||
this.config.getIdeMode() &&
|
||||
ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected
|
||||
this.config.getIdeMode() && ideClient.isDiffingEnabled()
|
||||
? ideClient.openDiff(this.params.file_path, editData.newContent)
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -10,16 +10,12 @@ const mockFixLLMEditWithInstruction = vi.hoisted(() => vi.fn());
|
||||
const mockGenerateJson = vi.hoisted(() => vi.fn());
|
||||
const mockOpenDiff = vi.hoisted(() => vi.fn());
|
||||
|
||||
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
|
||||
vi.mock('../ide/ide-client.js', () => ({
|
||||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
IDEConnectionStatus: {
|
||||
Connected: 'connected',
|
||||
Disconnected: 'disconnected',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/llm-edit-fixer.js', () => ({
|
||||
@@ -461,9 +457,7 @@ describe('SmartEditTool', () => {
|
||||
filePath = path.join(rootDir, testFile);
|
||||
ideClient = {
|
||||
openDiff: vi.fn(),
|
||||
getConnectionStatus: vi.fn().mockReturnValue({
|
||||
status: IDEConnectionStatus.Connected,
|
||||
}),
|
||||
isDiffingEnabled: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue(ideClient);
|
||||
(mockConfig as any).getIdeMode = () => true;
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
type ModifiableDeclarativeTool,
|
||||
type ModifyContext,
|
||||
} from './modifiable-tool.js';
|
||||
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js';
|
||||
|
||||
export function applyReplacement(
|
||||
@@ -528,8 +528,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
);
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const ideConfirmation =
|
||||
this.config.getIdeMode() &&
|
||||
ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected
|
||||
this.config.getIdeMode() && ideClient.isDiffingEnabled()
|
||||
? ideClient.openDiff(this.params.file_path, editData.newContent)
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '../utils/editCorrector.js';
|
||||
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
import type { DiffUpdateResult } from '../ide/ideContext.js';
|
||||
|
||||
const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root');
|
||||
@@ -45,18 +45,13 @@ vi.mock('../ide/ide-client.js', () => ({
|
||||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
IDEConnectionStatus: {
|
||||
Connected: 'connected',
|
||||
Disconnected: 'disconnected',
|
||||
Connecting: 'connecting',
|
||||
},
|
||||
}));
|
||||
let mockGeminiClientInstance: Mocked<GeminiClient>;
|
||||
const mockEnsureCorrectEdit = vi.fn<typeof ensureCorrectEdit>();
|
||||
const mockEnsureCorrectFileContent = vi.fn<typeof ensureCorrectFileContent>();
|
||||
const mockIdeClient = {
|
||||
getConnectionStatus: vi.fn(),
|
||||
openDiff: vi.fn(),
|
||||
isDiffingEnabled: vi.fn(),
|
||||
};
|
||||
|
||||
// Wire up the mocked functions to be used by the actual module imports
|
||||
@@ -456,9 +451,7 @@ describe('WriteFileTool', () => {
|
||||
beforeEach(() => {
|
||||
// Enable IDE mode and set connection status for these tests
|
||||
mockConfigInternal.getIdeMode.mockReturnValue(true);
|
||||
mockIdeClient.getConnectionStatus.mockReturnValue({
|
||||
status: IDEConnectionStatus.Connected,
|
||||
});
|
||||
mockIdeClient.isDiffingEnabled.mockReturnValue(true);
|
||||
mockIdeClient.openDiff.mockResolvedValue({
|
||||
status: 'accepted',
|
||||
content: 'ide-modified-content',
|
||||
@@ -495,9 +488,7 @@ describe('WriteFileTool', () => {
|
||||
});
|
||||
|
||||
it('should not call openDiff if IDE is not connected', async () => {
|
||||
mockIdeClient.getConnectionStatus.mockReturnValue({
|
||||
status: IDEConnectionStatus.Disconnected,
|
||||
});
|
||||
mockIdeClient.isDiffingEnabled.mockReturnValue(false);
|
||||
const filePath = path.join(rootDir, 'ide_disconnected_file.txt');
|
||||
const params = { file_path: filePath, content: 'test' };
|
||||
const invocation = tool.build(params);
|
||||
|
||||
@@ -35,7 +35,7 @@ import type {
|
||||
ModifiableDeclarativeTool,
|
||||
ModifyContext,
|
||||
} from './modifiable-tool.js';
|
||||
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
import { logFileOperation } from '../telemetry/loggers.js';
|
||||
import { FileOperationEvent } from '../telemetry/types.js';
|
||||
import { FileOperation } from '../telemetry/metrics.js';
|
||||
@@ -195,8 +195,7 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const ideConfirmation =
|
||||
this.config.getIdeMode() &&
|
||||
ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected
|
||||
this.config.getIdeMode() && ideClient.isDiffingEnabled()
|
||||
? ideClient.openDiff(this.params.file_path, correctedContent)
|
||||
: undefined;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user