feat(ide): Check for IDE diffing capabilities before opening diffs (#8266)

This commit is contained in:
Shreya Keshive
2025-09-11 16:17:57 -04:00
committed by GitHub
parent 59182a9fa0
commit 8969a232ec
9 changed files with 183 additions and 50 deletions
+90
View File
@@ -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);
});
});
});
+51
View File
@@ -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) {