mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Sync upstream Gemini-CLI v0.8.2 (#838)
This commit is contained in:
@@ -5,3 +5,6 @@
|
||||
*/
|
||||
|
||||
export const QWEN_CODE_COMPANION_EXTENSION_NAME = 'Qwen Code Companion';
|
||||
export const IDE_MAX_OPEN_FILES = 10;
|
||||
export const IDE_MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
|
||||
export const IDE_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { detectIde, DetectedIde, getIdeInfo } from './detect-ide.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { detectIde, IDE_DEFINITIONS } from './detect-ide.js';
|
||||
|
||||
describe('detectIde', () => {
|
||||
const ideProcessInfo = { pid: 123, command: 'some/path/to/code' };
|
||||
@@ -35,110 +35,103 @@ describe('detectIde', () => {
|
||||
it('should detect Devin', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('__COG_BASHRC_SOURCED', '1');
|
||||
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Devin);
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.devin);
|
||||
});
|
||||
|
||||
it('should detect Replit', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('REPLIT_USER', 'testuser');
|
||||
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Replit);
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.replit);
|
||||
});
|
||||
|
||||
it('should detect Cursor', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', 'some-id');
|
||||
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Cursor);
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cursor);
|
||||
});
|
||||
|
||||
it('should detect Codespaces', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('CODESPACES', 'true');
|
||||
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Codespaces);
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.codespaces);
|
||||
});
|
||||
|
||||
it('should detect Cloud Shell via EDITOR_IN_CLOUD_SHELL', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', 'true');
|
||||
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.CloudShell);
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell);
|
||||
});
|
||||
|
||||
it('should detect Cloud Shell via CLOUD_SHELL', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.CloudShell);
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell);
|
||||
});
|
||||
|
||||
it('should detect Trae', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('TERM_PRODUCT', 'Trae');
|
||||
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Trae);
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.trae);
|
||||
});
|
||||
|
||||
it('should detect Firebase Studio via MONOSPACE_ENV', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('MONOSPACE_ENV', 'true');
|
||||
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.FirebaseStudio);
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.firebasestudio);
|
||||
});
|
||||
|
||||
it('should detect VSCode when no other IDE is detected and command includes "code"', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('MONOSPACE_ENV', '');
|
||||
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.VSCode);
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.vscode);
|
||||
});
|
||||
|
||||
it('should detect VSCodeFork when no other IDE is detected and command does not include "code"', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('MONOSPACE_ENV', '');
|
||||
expect(detectIde(ideProcessInfoNoCode)).toBe(DetectedIde.VSCodeFork);
|
||||
expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectIde with ideInfoFromFile', () => {
|
||||
const ideProcessInfo = { pid: 123, command: 'some/path/to/code' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('__COG_BASHRC_SOURCED', '');
|
||||
vi.stubEnv('REPLIT_USER', '');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', '');
|
||||
vi.stubEnv('CODESPACES', '');
|
||||
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', '');
|
||||
vi.stubEnv('CLOUD_SHELL', '');
|
||||
vi.stubEnv('TERM_PRODUCT', '');
|
||||
vi.stubEnv('MONOSPACE_ENV', '');
|
||||
});
|
||||
|
||||
it('should prioritize other IDEs over VSCode detection', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should use the name and displayName from the file', () => {
|
||||
const ideInfoFromFile = {
|
||||
name: 'custom-ide',
|
||||
displayName: 'Custom IDE',
|
||||
};
|
||||
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toEqual(ideInfoFromFile);
|
||||
});
|
||||
|
||||
it('should fall back to env detection if name is missing', () => {
|
||||
const ideInfoFromFile = { displayName: 'Custom IDE' };
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('REPLIT_USER', 'testuser');
|
||||
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Replit);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIdeInfo', () => {
|
||||
it('should return correct info for Devin', () => {
|
||||
expect(getIdeInfo(DetectedIde.Devin)).toEqual({ displayName: 'Devin' });
|
||||
});
|
||||
|
||||
it('should return correct info for Replit', () => {
|
||||
expect(getIdeInfo(DetectedIde.Replit)).toEqual({ displayName: 'Replit' });
|
||||
});
|
||||
|
||||
it('should return correct info for Cursor', () => {
|
||||
expect(getIdeInfo(DetectedIde.Cursor)).toEqual({ displayName: 'Cursor' });
|
||||
});
|
||||
|
||||
it('should return correct info for CloudShell', () => {
|
||||
expect(getIdeInfo(DetectedIde.CloudShell)).toEqual({
|
||||
displayName: 'Cloud Shell',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct info for Codespaces', () => {
|
||||
expect(getIdeInfo(DetectedIde.Codespaces)).toEqual({
|
||||
displayName: 'GitHub Codespaces',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct info for FirebaseStudio', () => {
|
||||
expect(getIdeInfo(DetectedIde.FirebaseStudio)).toEqual({
|
||||
displayName: 'Firebase Studio',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct info for Trae', () => {
|
||||
expect(getIdeInfo(DetectedIde.Trae)).toEqual({ displayName: 'Trae' });
|
||||
});
|
||||
|
||||
it('should return correct info for VSCode', () => {
|
||||
expect(getIdeInfo(DetectedIde.VSCode)).toEqual({ displayName: 'VS Code' });
|
||||
});
|
||||
|
||||
it('should return correct info for VSCodeFork', () => {
|
||||
expect(getIdeInfo(DetectedIde.VSCodeFork)).toEqual({ displayName: 'IDE' });
|
||||
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe(
|
||||
IDE_DEFINITIONS.vscode,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to env detection if displayName is missing', () => {
|
||||
const ideInfoFromFile = { name: 'custom-ide' };
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe(
|
||||
IDE_DEFINITIONS.vscode,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,113 +4,82 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export enum DetectedIde {
|
||||
Devin = 'devin',
|
||||
Replit = 'replit',
|
||||
Cursor = 'cursor',
|
||||
CloudShell = 'cloudshell',
|
||||
Codespaces = 'codespaces',
|
||||
FirebaseStudio = 'firebasestudio',
|
||||
Trae = 'trae',
|
||||
VSCode = 'vscode',
|
||||
VSCodeFork = 'vscodefork',
|
||||
}
|
||||
export const IDE_DEFINITIONS = {
|
||||
devin: { name: 'devin', displayName: 'Devin' },
|
||||
replit: { name: 'replit', displayName: 'Replit' },
|
||||
cursor: { name: 'cursor', displayName: 'Cursor' },
|
||||
cloudshell: { name: 'cloudshell', displayName: 'Cloud Shell' },
|
||||
codespaces: { name: 'codespaces', displayName: 'GitHub Codespaces' },
|
||||
firebasestudio: { name: 'firebasestudio', displayName: 'Firebase Studio' },
|
||||
trae: { name: 'trae', displayName: 'Trae' },
|
||||
vscode: { name: 'vscode', displayName: 'VS Code' },
|
||||
vscodefork: { name: 'vscodefork', displayName: 'IDE' },
|
||||
} as const;
|
||||
|
||||
export interface IdeInfo {
|
||||
name: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function getIdeInfo(ide: DetectedIde): IdeInfo {
|
||||
switch (ide) {
|
||||
case DetectedIde.Devin:
|
||||
return {
|
||||
displayName: 'Devin',
|
||||
};
|
||||
case DetectedIde.Replit:
|
||||
return {
|
||||
displayName: 'Replit',
|
||||
};
|
||||
case DetectedIde.Cursor:
|
||||
return {
|
||||
displayName: 'Cursor',
|
||||
};
|
||||
case DetectedIde.CloudShell:
|
||||
return {
|
||||
displayName: 'Cloud Shell',
|
||||
};
|
||||
case DetectedIde.Codespaces:
|
||||
return {
|
||||
displayName: 'GitHub Codespaces',
|
||||
};
|
||||
case DetectedIde.FirebaseStudio:
|
||||
return {
|
||||
displayName: 'Firebase Studio',
|
||||
};
|
||||
case DetectedIde.Trae:
|
||||
return {
|
||||
displayName: 'Trae',
|
||||
};
|
||||
case DetectedIde.VSCode:
|
||||
return {
|
||||
displayName: 'VS Code',
|
||||
};
|
||||
case DetectedIde.VSCodeFork:
|
||||
return {
|
||||
displayName: 'IDE',
|
||||
};
|
||||
default: {
|
||||
// This ensures that if a new IDE is added to the enum, we get a compile-time error.
|
||||
const exhaustiveCheck: never = ide;
|
||||
return exhaustiveCheck;
|
||||
}
|
||||
}
|
||||
export function isCloudShell(): boolean {
|
||||
return !!(process.env['EDITOR_IN_CLOUD_SHELL'] || process.env['CLOUD_SHELL']);
|
||||
}
|
||||
|
||||
export function detectIdeFromEnv(): DetectedIde {
|
||||
export function detectIdeFromEnv(): IdeInfo {
|
||||
if (process.env['__COG_BASHRC_SOURCED']) {
|
||||
return DetectedIde.Devin;
|
||||
return IDE_DEFINITIONS.devin;
|
||||
}
|
||||
if (process.env['REPLIT_USER']) {
|
||||
return DetectedIde.Replit;
|
||||
return IDE_DEFINITIONS.replit;
|
||||
}
|
||||
if (process.env['CURSOR_TRACE_ID']) {
|
||||
return DetectedIde.Cursor;
|
||||
return IDE_DEFINITIONS.cursor;
|
||||
}
|
||||
if (process.env['CODESPACES']) {
|
||||
return DetectedIde.Codespaces;
|
||||
return IDE_DEFINITIONS.codespaces;
|
||||
}
|
||||
if (process.env['EDITOR_IN_CLOUD_SHELL'] || process.env['CLOUD_SHELL']) {
|
||||
return DetectedIde.CloudShell;
|
||||
if (isCloudShell()) {
|
||||
return IDE_DEFINITIONS.cloudshell;
|
||||
}
|
||||
if (process.env['TERM_PRODUCT'] === 'Trae') {
|
||||
return DetectedIde.Trae;
|
||||
return IDE_DEFINITIONS.trae;
|
||||
}
|
||||
if (process.env['MONOSPACE_ENV']) {
|
||||
return DetectedIde.FirebaseStudio;
|
||||
return IDE_DEFINITIONS.firebasestudio;
|
||||
}
|
||||
return DetectedIde.VSCode;
|
||||
return IDE_DEFINITIONS.vscode;
|
||||
}
|
||||
|
||||
function verifyVSCode(
|
||||
ide: DetectedIde,
|
||||
ide: IdeInfo,
|
||||
ideProcessInfo: {
|
||||
pid: number;
|
||||
command: string;
|
||||
},
|
||||
): DetectedIde {
|
||||
if (ide !== DetectedIde.VSCode) {
|
||||
): IdeInfo {
|
||||
if (ide.name !== IDE_DEFINITIONS.vscode.name) {
|
||||
return ide;
|
||||
}
|
||||
if (ideProcessInfo.command.toLowerCase().includes('code')) {
|
||||
return DetectedIde.VSCode;
|
||||
return IDE_DEFINITIONS.vscode;
|
||||
}
|
||||
return DetectedIde.VSCodeFork;
|
||||
return IDE_DEFINITIONS.vscodefork;
|
||||
}
|
||||
|
||||
export function detectIde(ideProcessInfo: {
|
||||
pid: number;
|
||||
command: string;
|
||||
}): DetectedIde | undefined {
|
||||
export function detectIde(
|
||||
ideProcessInfo: {
|
||||
pid: number;
|
||||
command: string;
|
||||
},
|
||||
ideInfoFromFile?: { name?: string; displayName?: string },
|
||||
): IdeInfo | undefined {
|
||||
if (ideInfoFromFile?.name && ideInfoFromFile.displayName) {
|
||||
return {
|
||||
name: ideInfoFromFile.name,
|
||||
displayName: ideInfoFromFile.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
// Only VSCode-based integrations are currently supported.
|
||||
if (process.env['TERM_PROGRAM'] !== 'vscode') {
|
||||
return undefined;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mocked,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { IdeClient, IDEConnectionStatus } from './ide-client.js';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -19,21 +20,18 @@ import { getIdeProcessInfo } from './process-utils.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import {
|
||||
detectIde,
|
||||
DetectedIde,
|
||||
getIdeInfo,
|
||||
type IdeInfo,
|
||||
} from './detect-ide.js';
|
||||
import { detectIde, IDE_DEFINITIONS } from './detect-ide.js';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
const actual = await importOriginal<typeof fs>();
|
||||
return {
|
||||
...(actual as object),
|
||||
promises: {
|
||||
...actual.promises,
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
},
|
||||
realpathSync: (p: string) => p,
|
||||
existsSync: () => false,
|
||||
@@ -64,10 +62,7 @@ describe('IdeClient', () => {
|
||||
|
||||
// Mock dependencies
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');
|
||||
vi.mocked(detectIde).mockReturnValue(DetectedIde.VSCode);
|
||||
vi.mocked(getIdeInfo).mockReturnValue({
|
||||
displayName: 'VS Code',
|
||||
} as IdeInfo);
|
||||
vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode);
|
||||
vi.mocked(getIdeProcessInfo).mockResolvedValue({
|
||||
pid: 12345,
|
||||
command: 'test-ide',
|
||||
@@ -80,6 +75,7 @@ describe('IdeClient', () => {
|
||||
close: vi.fn(),
|
||||
setNotificationHandler: vi.fn(),
|
||||
callTool: vi.fn(),
|
||||
request: vi.fn(),
|
||||
} as unknown as Mocked<Client>;
|
||||
mockHttpTransport = {
|
||||
close: vi.fn(),
|
||||
@@ -100,29 +96,14 @@ describe('IdeClient', () => {
|
||||
});
|
||||
|
||||
describe('connect', () => {
|
||||
it('should return invalid if QWEN_CODE_IDE_WORKSPACE_PATH is undefined', () => {
|
||||
const result = IdeClient.validateWorkspacePath(
|
||||
undefined,
|
||||
'VS Code',
|
||||
'/Users/person/gemini-cli/sub-dir',
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('Failed to connect');
|
||||
});
|
||||
|
||||
it('should return invalid if QWEN_CODE_IDE_WORKSPACE_PATH is empty', () => {
|
||||
const result = IdeClient.validateWorkspacePath(
|
||||
'',
|
||||
'VS Code',
|
||||
'/Users/person/gemini-cli/sub-dir',
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain('please open a workspace folder');
|
||||
});
|
||||
|
||||
it('should connect using HTTP when port is provided in config file', 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([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
@@ -144,6 +125,11 @@ describe('IdeClient', () => {
|
||||
it('should connect using stdio when stdio config is provided in file', async () => {
|
||||
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
@@ -164,6 +150,11 @@ describe('IdeClient', () => {
|
||||
stdio: { command: 'test-cmd', args: ['--foo'] },
|
||||
};
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
@@ -179,6 +170,11 @@ describe('IdeClient', () => {
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValue(
|
||||
new Error('File not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '9090';
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
@@ -198,6 +194,12 @@ describe('IdeClient', () => {
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValue(
|
||||
new Error('File not found'),
|
||||
);
|
||||
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
process.env['QWEN_CODE_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd';
|
||||
process.env['QWEN_CODE_IDE_SERVER_STDIO_ARGS'] = '["--bar"]';
|
||||
|
||||
@@ -217,6 +219,11 @@ describe('IdeClient', () => {
|
||||
it('should prioritize file config over environment variables', 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([]);
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '9090';
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
@@ -235,6 +242,11 @@ describe('IdeClient', () => {
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValue(
|
||||
new Error('File not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
@@ -249,4 +261,422 @@ describe('IdeClient', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnectionConfigFromFile', () => {
|
||||
it('should return config from the specific pid file if it exists', async () => {
|
||||
const config = { port: '1234', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
// In tests, the private method can be accessed like this.
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(config);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return undefined if no config files are found', async () => {
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found'));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should find and parse a single config file with the new naming scheme', async () => {
|
||||
const config = { port: '5678', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
); // For old path
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(config);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter out configs with invalid workspace paths', async () => {
|
||||
const validConfig = {
|
||||
port: '5678',
|
||||
workspacePath: '/test/workspace',
|
||||
};
|
||||
const invalidConfig = {
|
||||
port: '1111',
|
||||
workspacePath: '/invalid/workspace',
|
||||
};
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
|
||||
.mockResolvedValueOnce(JSON.stringify(validConfig));
|
||||
|
||||
const validateSpy = vi
|
||||
.spyOn(IdeClient, 'validateWorkspacePath')
|
||||
.mockReturnValueOnce({ isValid: false })
|
||||
.mockReturnValueOnce({ isValid: true });
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(validConfig);
|
||||
expect(validateSpy).toHaveBeenCalledWith(
|
||||
'/invalid/workspace',
|
||||
'/test/workspace/sub-dir',
|
||||
);
|
||||
expect(validateSpy).toHaveBeenCalledWith(
|
||||
'/test/workspace',
|
||||
'/test/workspace/sub-dir',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the first valid config when multiple workspaces are valid', async () => {
|
||||
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
||||
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(config1);
|
||||
});
|
||||
|
||||
it('should prioritize the config matching the port from the environment variable', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
|
||||
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
||||
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(config2);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
|
||||
it('should handle invalid JSON in one of the config files', async () => {
|
||||
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce('invalid json')
|
||||
.mockResolvedValueOnce(JSON.stringify(validConfig));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(validConfig);
|
||||
});
|
||||
|
||||
it('should return undefined if readdir throws an error', async () => {
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
vi.mocked(fs.promises.readdir).mockRejectedValue(
|
||||
new Error('readdir failed'),
|
||||
);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore files with invalid names', async () => {
|
||||
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json', // valid
|
||||
'not-a-config-file.txt', // invalid
|
||||
'qwen-code-ide-server-asdf.json', // invalid
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify(validConfig),
|
||||
);
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(validConfig);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'),
|
||||
'utf8',
|
||||
);
|
||||
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
|
||||
path.join('/tmp/gemini/ide', 'not-a-config-file.txt'),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should match env port string to a number port in the config', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
|
||||
const config1 = { port: 1111, workspacePath: '/test/workspace' };
|
||||
const config2 = { port: 3333, workspacePath: '/test/workspace2' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(config2);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication', () => {
|
||||
it('should connect with an auth token if provided in the discovery file', async () => {
|
||||
const authToken = 'test-auth-token';
|
||||
const config = { port: '8080', authToken };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||
new URL('http://localhost:8080/mcp'),
|
||||
expect.objectContaining({
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,22 +6,24 @@
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
import { detectIde, type DetectedIde, getIdeInfo } from '../ide/detect-ide.js';
|
||||
import type { DiffUpdateResult } from '../ide/ideContext.js';
|
||||
import { detectIde, type IdeInfo } from '../ide/detect-ide.js';
|
||||
import { ideContextStore } from './ideContext.js';
|
||||
import {
|
||||
ideContext,
|
||||
IdeContextNotificationSchema,
|
||||
IdeDiffAcceptedNotificationSchema,
|
||||
IdeDiffClosedNotificationSchema,
|
||||
CloseDiffResponseSchema,
|
||||
} from '../ide/ideContext.js';
|
||||
IdeDiffRejectedNotificationSchema,
|
||||
} from './types.js';
|
||||
import { getIdeProcessInfo } from './process-utils.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EnvHttpProxyAgent } from 'undici';
|
||||
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { IDE_REQUEST_TIMEOUT_MS } from './constants.js';
|
||||
|
||||
const logger = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -30,6 +32,16 @@ const logger = {
|
||||
error: (...args: any[]) => console.error('[ERROR] [IDEClient]', ...args),
|
||||
};
|
||||
|
||||
export type DiffUpdateResult =
|
||||
| {
|
||||
status: 'accepted';
|
||||
content?: string;
|
||||
}
|
||||
| {
|
||||
status: 'rejected';
|
||||
content: undefined;
|
||||
};
|
||||
|
||||
export type IDEConnectionState = {
|
||||
status: IDEConnectionStatus;
|
||||
details?: string; // User-facing
|
||||
@@ -48,6 +60,7 @@ type StdioConfig = {
|
||||
|
||||
type ConnectionConfig = {
|
||||
port?: string;
|
||||
authToken?: string;
|
||||
stdio?: StdioConfig;
|
||||
};
|
||||
|
||||
@@ -65,34 +78,46 @@ function getRealPath(path: string): string {
|
||||
* Manages the connection to and interaction with the IDE server.
|
||||
*/
|
||||
export class IdeClient {
|
||||
private static instance: IdeClient;
|
||||
private static instancePromise: Promise<IdeClient> | null = null;
|
||||
private client: Client | undefined = undefined;
|
||||
private state: IDEConnectionState = {
|
||||
status: IDEConnectionStatus.Disconnected,
|
||||
details:
|
||||
'IDE integration is currently disabled. To enable it, run /ide enable.',
|
||||
};
|
||||
private currentIde: DetectedIde | undefined;
|
||||
private currentIdeDisplayName: string | undefined;
|
||||
private currentIde: IdeInfo | undefined;
|
||||
private ideProcessInfo: { pid: number; command: string } | undefined;
|
||||
private connectionConfig:
|
||||
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||
| undefined;
|
||||
private authToken: string | undefined;
|
||||
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
|
||||
* can't handle multiple diff views being opened simultaneously.
|
||||
*/
|
||||
private diffMutex = Promise.resolve();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static async getInstance(): Promise<IdeClient> {
|
||||
if (!IdeClient.instance) {
|
||||
const client = new IdeClient();
|
||||
client.ideProcessInfo = await getIdeProcessInfo();
|
||||
client.currentIde = detectIde(client.ideProcessInfo);
|
||||
if (client.currentIde) {
|
||||
client.currentIdeDisplayName = getIdeInfo(
|
||||
client.currentIde,
|
||||
).displayName;
|
||||
}
|
||||
IdeClient.instance = client;
|
||||
static getInstance(): Promise<IdeClient> {
|
||||
if (!IdeClient.instancePromise) {
|
||||
IdeClient.instancePromise = (async () => {
|
||||
const client = new IdeClient();
|
||||
client.ideProcessInfo = await getIdeProcessInfo();
|
||||
client.connectionConfig = await client.getConnectionConfigFromFile();
|
||||
client.currentIde = detectIde(
|
||||
client.ideProcessInfo,
|
||||
client.connectionConfig?.ideInfo,
|
||||
);
|
||||
return client;
|
||||
})();
|
||||
}
|
||||
return IdeClient.instance;
|
||||
return IdeClient.instancePromise;
|
||||
}
|
||||
|
||||
addStatusChangeListener(listener: (state: IDEConnectionState) => void) {
|
||||
@@ -103,8 +128,16 @@ export class IdeClient {
|
||||
this.statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
addTrustChangeListener(listener: (isTrusted: boolean) => void) {
|
||||
this.trustChangeListeners.add(listener);
|
||||
}
|
||||
|
||||
removeTrustChangeListener(listener: (isTrusted: boolean) => void) {
|
||||
this.trustChangeListeners.delete(listener);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (!this.currentIde || !this.currentIdeDisplayName) {
|
||||
if (!this.currentIde) {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks`,
|
||||
@@ -115,14 +148,16 @@ export class IdeClient {
|
||||
|
||||
this.setState(IDEConnectionStatus.Connecting);
|
||||
|
||||
const configFromFile = await this.getConnectionConfigFromFile();
|
||||
this.connectionConfig = await this.getConnectionConfigFromFile();
|
||||
if (this.connectionConfig?.authToken) {
|
||||
this.authToken = this.connectionConfig.authToken;
|
||||
}
|
||||
const workspacePath =
|
||||
configFromFile?.workspacePath ??
|
||||
this.connectionConfig?.workspacePath ??
|
||||
process.env['QWEN_CODE_IDE_WORKSPACE_PATH'];
|
||||
|
||||
const { isValid, error } = IdeClient.validateWorkspacePath(
|
||||
workspacePath,
|
||||
this.currentIdeDisplayName,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
@@ -131,18 +166,18 @@ export class IdeClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (configFromFile) {
|
||||
if (configFromFile.port) {
|
||||
if (this.connectionConfig) {
|
||||
if (this.connectionConfig.port) {
|
||||
const connected = await this.establishHttpConnection(
|
||||
configFromFile.port,
|
||||
this.connectionConfig.port,
|
||||
);
|
||||
if (connected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (configFromFile.stdio) {
|
||||
if (this.connectionConfig.stdio) {
|
||||
const connected = await this.establishStdioConnection(
|
||||
configFromFile.stdio,
|
||||
this.connectionConfig.stdio,
|
||||
);
|
||||
if (connected) {
|
||||
return;
|
||||
@@ -168,70 +203,185 @@ export class IdeClient {
|
||||
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`Failed to connect to IDE companion extension in ${this.currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
|
||||
`Failed to connect to IDE companion extension in ${this.currentIde.displayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A diff is accepted with any modifications if the user performs one of the
|
||||
* following actions:
|
||||
* - Clicks the checkbox icon in the IDE to accept
|
||||
* - Runs `command+shift+p` > "Gemini CLI: Accept Diff in IDE" to accept
|
||||
* - Selects "accept" in the CLI UI
|
||||
* - Saves the file via `ctrl/command+s`
|
||||
* Opens a diff view in the IDE, allowing the user to review and accept or
|
||||
* reject changes.
|
||||
*
|
||||
* A diff is rejected if the user performs one of the following actions:
|
||||
* - Clicks the "x" icon in the IDE
|
||||
* - Runs "Gemini CLI: Close Diff in IDE"
|
||||
* - Selects "no" in the CLI UI
|
||||
* - Closes the file
|
||||
* This method sends a request to the IDE to display a diff between the
|
||||
* current content of a file and the new content provided. It then waits for
|
||||
* a notification from the IDE indicating that the user has either accepted
|
||||
* (potentially with manual edits) or rejected the diff.
|
||||
*
|
||||
* A mutex ensures that only one diff view can be open at a time to prevent
|
||||
* race conditions.
|
||||
*
|
||||
* @param filePath The absolute path to the file to be diffed.
|
||||
* @param newContent The proposed new content for the file.
|
||||
* @returns A promise that resolves with a `DiffUpdateResult`, indicating
|
||||
* whether the diff was 'accepted' or 'rejected' and including the final
|
||||
* content if accepted.
|
||||
*/
|
||||
async openDiff(
|
||||
filePath: string,
|
||||
newContent?: string,
|
||||
newContent: string,
|
||||
): Promise<DiffUpdateResult> {
|
||||
return new Promise<DiffUpdateResult>((resolve, reject) => {
|
||||
const release = await this.acquireMutex();
|
||||
|
||||
const promise = new Promise<DiffUpdateResult>((resolve, reject) => {
|
||||
if (!this.client) {
|
||||
// The promise will be rejected, and the finally block below will release the mutex.
|
||||
return reject(new Error('IDE client is not connected.'));
|
||||
}
|
||||
this.diffResponses.set(filePath, resolve);
|
||||
this.client
|
||||
?.callTool({
|
||||
name: `openDiff`,
|
||||
arguments: {
|
||||
filePath,
|
||||
newContent,
|
||||
.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: `openDiff`,
|
||||
arguments: {
|
||||
filePath,
|
||||
newContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
CallToolResultSchema,
|
||||
{ timeout: IDE_REQUEST_TIMEOUT_MS },
|
||||
)
|
||||
.then((parsedResultData) => {
|
||||
if (parsedResultData.isError) {
|
||||
const textPart = parsedResultData.content.find(
|
||||
(part) => part.type === 'text',
|
||||
);
|
||||
const errorMessage =
|
||||
textPart?.text ?? `Tool 'openDiff' reported an error.`;
|
||||
logger.debug(
|
||||
`Request for openDiff ${filePath} failed with isError:`,
|
||||
errorMessage,
|
||||
);
|
||||
this.diffResponses.delete(filePath);
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.debug(`callTool for ${filePath} failed:`, err);
|
||||
logger.debug(`Request for openDiff ${filePath} failed:`, err);
|
||||
this.diffResponses.delete(filePath);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure the mutex is released only after the diff interaction is complete.
|
||||
promise.finally(release);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
async closeDiff(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await this.client?.callTool({
|
||||
name: `closeDiff`,
|
||||
arguments: {
|
||||
filePath,
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Acquires a lock to ensure sequential execution of critical sections.
|
||||
*
|
||||
* This method implements a promise-based mutex. It works by chaining promises.
|
||||
* Each call to `acquireMutex` gets the current `diffMutex` promise. It then
|
||||
* creates a *new* promise (`newMutex`) that will be resolved when the caller
|
||||
* invokes the returned `release` function. The `diffMutex` is immediately
|
||||
* updated to this `newMutex`.
|
||||
*
|
||||
* The method returns a promise that resolves with the `release` function only
|
||||
* *after* the *previous* `diffMutex` promise has resolved. This creates a
|
||||
* queue where each subsequent operation must wait for the previous one to release
|
||||
* the lock.
|
||||
*
|
||||
* @returns A promise that resolves to a function that must be called to
|
||||
* release the lock.
|
||||
*/
|
||||
private acquireMutex(): Promise<() => void> {
|
||||
let release: () => void;
|
||||
const newMutex = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const oldMutex = this.diffMutex;
|
||||
this.diffMutex = newMutex;
|
||||
return oldMutex.then(() => release);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
const parsed = CloseDiffResponseSchema.parse(result);
|
||||
return parsed.content;
|
||||
async closeDiff(
|
||||
filePath: string,
|
||||
options?: { suppressNotification?: boolean },
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
if (!this.client) {
|
||||
return undefined;
|
||||
}
|
||||
const resultData = await this.client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: `closeDiff`,
|
||||
arguments: {
|
||||
filePath,
|
||||
suppressNotification: options?.suppressNotification,
|
||||
},
|
||||
},
|
||||
},
|
||||
CallToolResultSchema,
|
||||
{ timeout: IDE_REQUEST_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
if (!resultData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (resultData.isError) {
|
||||
const textPart = resultData.content.find(
|
||||
(part) => part.type === 'text',
|
||||
);
|
||||
const errorMessage =
|
||||
textPart?.text ?? `Tool 'closeDiff' reported an error.`;
|
||||
logger.debug(
|
||||
`Request for closeDiff ${filePath} failed with isError:`,
|
||||
errorMessage,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const textPart = resultData.content.find((part) => part.type === 'text');
|
||||
|
||||
if (textPart?.text) {
|
||||
try {
|
||||
const parsedJson = JSON.parse(textPart.text);
|
||||
if (parsedJson && typeof parsedJson.content === 'string') {
|
||||
return parsedJson.content;
|
||||
}
|
||||
if (parsedJson && parsedJson.content === null) {
|
||||
return undefined;
|
||||
}
|
||||
} catch (_e) {
|
||||
logger.debug(
|
||||
`Invalid JSON in closeDiff response for ${filePath}:`,
|
||||
textPart.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(`callTool for ${filePath} failed:`, err);
|
||||
logger.debug(`Request for closeDiff ${filePath} failed:`, err);
|
||||
}
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Closes the diff. Instead of waiting for a notification,
|
||||
// manually resolves the diff resolver as the desired outcome.
|
||||
async resolveDiffFromCli(filePath: string, outcome: 'accepted' | 'rejected') {
|
||||
const content = await this.closeDiff(filePath);
|
||||
const resolver = this.diffResponses.get(filePath);
|
||||
const content = await this.closeDiff(filePath, {
|
||||
// Suppress notification to avoid race where closing the diff rejects the
|
||||
// request.
|
||||
suppressNotification: true,
|
||||
});
|
||||
|
||||
if (resolver) {
|
||||
if (outcome === 'accepted') {
|
||||
resolver({ status: 'accepted', content });
|
||||
@@ -257,7 +407,7 @@ export class IdeClient {
|
||||
this.client?.close();
|
||||
}
|
||||
|
||||
getCurrentIde(): DetectedIde | undefined {
|
||||
getCurrentIde(): IdeInfo | undefined {
|
||||
return this.currentIde;
|
||||
}
|
||||
|
||||
@@ -266,7 +416,54 @@ export class IdeClient {
|
||||
}
|
||||
|
||||
getDetectedIdeDisplayName(): string | undefined {
|
||||
return this.currentIdeDisplayName;
|
||||
return this.currentIde?.displayName;
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -297,26 +494,25 @@ export class IdeClient {
|
||||
}
|
||||
|
||||
if (status === IDEConnectionStatus.Disconnected) {
|
||||
ideContext.clearIdeContext();
|
||||
ideContextStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
static validateWorkspacePath(
|
||||
ideWorkspacePath: string | undefined,
|
||||
currentIdeDisplayName: string | undefined,
|
||||
cwd: string,
|
||||
): { isValid: boolean; error?: string } {
|
||||
if (ideWorkspacePath === undefined) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Failed to connect to IDE companion extension in ${currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
|
||||
error: `Failed to connect to IDE companion extension. Please ensure the extension is running. To install the extension, run /ide install.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ideWorkspacePath === '') {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `To use this feature, please open a workspace folder in ${currentIdeDisplayName} and try again.`,
|
||||
error: `To use this feature, please open a workspace folder in your IDE and try again.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -330,7 +526,7 @@ export class IdeClient {
|
||||
if (!isWithinWorkspace) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Directory mismatch. Qwen Code is running in a different location than the open workspace in ${currentIdeDisplayName}. Please run the CLI from one of the following directories: ${ideWorkspacePaths.join(
|
||||
error: `Directory mismatch. Qwen Code is running in a different location than the open workspace in the IDE. Please run the CLI from one of the following directories: ${ideWorkspacePaths.join(
|
||||
', ',
|
||||
)}`,
|
||||
};
|
||||
@@ -373,11 +569,14 @@ export class IdeClient {
|
||||
}
|
||||
|
||||
private async getConnectionConfigFromFile(): Promise<
|
||||
(ConnectionConfig & { workspacePath?: string }) | undefined
|
||||
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||
| undefined
|
||||
> {
|
||||
if (!this.ideProcessInfo) {
|
||||
return {};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// For backwards compatability
|
||||
try {
|
||||
const portFile = path.join(
|
||||
os.tmpdir(),
|
||||
@@ -386,8 +585,85 @@ export class IdeClient {
|
||||
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
|
||||
return JSON.parse(portFileContents);
|
||||
} catch (_) {
|
||||
// For newer extension versions, the file name matches the pattern
|
||||
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
|
||||
// windows are open, multiple files matching the pattern are expected to
|
||||
// exist.
|
||||
}
|
||||
|
||||
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
|
||||
let portFiles;
|
||||
try {
|
||||
portFiles = await fs.promises.readdir(portFileDir);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read IDE connection directory:', e);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!portFiles) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fileRegex = new RegExp(
|
||||
`^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
|
||||
);
|
||||
const matchingFiles = portFiles
|
||||
.filter((file) => fileRegex.test(file))
|
||||
.sort();
|
||||
if (matchingFiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let fileContents: string[];
|
||||
try {
|
||||
fileContents = await Promise.all(
|
||||
matchingFiles.map((file) =>
|
||||
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read IDE connection config file(s):', e);
|
||||
return undefined;
|
||||
}
|
||||
const parsedContents = fileContents.map((content) => {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to parse JSON from config file: ', e);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const validWorkspaces = parsedContents.filter((content) => {
|
||||
if (!content) {
|
||||
return false;
|
||||
}
|
||||
const { isValid } = IdeClient.validateWorkspacePath(
|
||||
content.workspacePath,
|
||||
process.cwd(),
|
||||
);
|
||||
return isValid;
|
||||
});
|
||||
|
||||
if (validWorkspaces.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (validWorkspaces.length === 1) {
|
||||
return validWorkspaces[0];
|
||||
}
|
||||
|
||||
const portFromEnv = this.getPortFromEnv();
|
||||
if (portFromEnv) {
|
||||
const matchingPort = validWorkspaces.find(
|
||||
(content) => String(content.port) === portFromEnv,
|
||||
);
|
||||
if (matchingPort) {
|
||||
return matchingPort;
|
||||
}
|
||||
}
|
||||
|
||||
return validWorkspaces[0];
|
||||
}
|
||||
|
||||
private createProxyAwareFetch() {
|
||||
@@ -414,7 +690,7 @@ export class IdeClient {
|
||||
return new Response(response.body as ReadableStream<unknown> | null, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: standardHeaders,
|
||||
headers: [...response.headers.entries()],
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -427,20 +703,27 @@ export class IdeClient {
|
||||
this.client.setNotificationHandler(
|
||||
IdeContextNotificationSchema,
|
||||
(notification) => {
|
||||
ideContext.setIdeContext(notification.params);
|
||||
ideContextStore.set(notification.params);
|
||||
const isTrusted = notification.params.workspaceState?.isTrusted;
|
||||
if (isTrusted !== undefined) {
|
||||
for (const listener of this.trustChangeListeners) {
|
||||
listener(isTrusted);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
this.client.onerror = (_error) => {
|
||||
const errorMessage = _error instanceof Error ? _error.message : `_error`;
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
|
||||
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable\n${errorMessage}`,
|
||||
true,
|
||||
);
|
||||
};
|
||||
this.client.onclose = () => {
|
||||
this.setState(
|
||||
IDEConnectionStatus.Disconnected,
|
||||
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
|
||||
`IDE connection closed. To reconnect, run /ide enable.`,
|
||||
true,
|
||||
);
|
||||
};
|
||||
@@ -458,6 +741,22 @@ export class IdeClient {
|
||||
},
|
||||
);
|
||||
|
||||
this.client.setNotificationHandler(
|
||||
IdeDiffRejectedNotificationSchema,
|
||||
(notification) => {
|
||||
const { filePath } = notification.params;
|
||||
const resolver = this.diffResponses.get(filePath);
|
||||
if (resolver) {
|
||||
resolver({ status: 'rejected', content: undefined });
|
||||
this.diffResponses.delete(filePath);
|
||||
} else {
|
||||
logger.debug(`No resolver found for ${filePath}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// For backwards compatability. Newer extension versions will only send
|
||||
// IdeDiffRejectedNotificationSchema.
|
||||
this.client.setNotificationHandler(
|
||||
IdeDiffClosedNotificationSchema,
|
||||
(notification) => {
|
||||
@@ -487,6 +786,11 @@ export class IdeClient {
|
||||
new URL(`http://${getIdeServerHost()}:${port}/mcp`),
|
||||
{
|
||||
fetch: this.createProxyAwareFetch(),
|
||||
requestInit: {
|
||||
headers: this.authToken
|
||||
? { Authorization: `Bearer ${this.authToken}` }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -494,6 +798,7 @@ export class IdeClient {
|
||||
|
||||
await this.client.connect(transport);
|
||||
this.registerClientHandlers();
|
||||
await this.discoverTools();
|
||||
this.setState(IDEConnectionStatus.Connected);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
@@ -527,6 +832,7 @@ export class IdeClient {
|
||||
});
|
||||
await this.client.connect(transport);
|
||||
this.registerClientHandlers();
|
||||
await this.discoverTools();
|
||||
this.setState(IDEConnectionStatus.Connected);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
|
||||
@@ -4,17 +4,27 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual =
|
||||
(await importOriginal()) as typeof import('node:child_process');
|
||||
return {
|
||||
...actual,
|
||||
execSync: vi.fn(),
|
||||
spawnSync: vi.fn(() => ({ status: 0 })),
|
||||
};
|
||||
});
|
||||
vi.mock('fs');
|
||||
vi.mock('os');
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { getIdeInstaller } from './ide-installer.js';
|
||||
import * as child_process from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { DetectedIde } from './detect-ide.js';
|
||||
|
||||
vi.mock('child_process');
|
||||
vi.mock('fs');
|
||||
vi.mock('os');
|
||||
import { IDE_DEFINITIONS, type IdeInfo } from './detect-ide.js';
|
||||
|
||||
describe('ide-installer', () => {
|
||||
const HOME_DIR = '/home/user';
|
||||
@@ -28,23 +38,28 @@ describe('ide-installer', () => {
|
||||
});
|
||||
|
||||
describe('getIdeInstaller', () => {
|
||||
it.each([{ ide: DetectedIde.VSCode }, { ide: DetectedIde.FirebaseStudio }])(
|
||||
'returns a VsCodeInstaller for "$ide"',
|
||||
({ ide }) => {
|
||||
const installer = getIdeInstaller(ide);
|
||||
it.each([
|
||||
{ ide: IDE_DEFINITIONS.vscode },
|
||||
{ ide: IDE_DEFINITIONS.firebasestudio },
|
||||
])('returns a VsCodeInstaller for "$ide.name"', ({ ide }) => {
|
||||
const installer = getIdeInstaller(ide);
|
||||
|
||||
expect(installer).not.toBeNull();
|
||||
expect(installer?.install).toEqual(expect.any(Function));
|
||||
},
|
||||
);
|
||||
expect(installer).not.toBeNull();
|
||||
expect(installer?.install).toEqual(expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('VsCodeInstaller', () => {
|
||||
function setup({
|
||||
ide = DetectedIde.VSCode,
|
||||
ide = IDE_DEFINITIONS.vscode,
|
||||
existsResult = false,
|
||||
execSync = () => '',
|
||||
platform = 'linux' as NodeJS.Platform,
|
||||
}: {
|
||||
ide?: IdeInfo;
|
||||
existsResult?: boolean;
|
||||
execSync?: () => string;
|
||||
platform?: NodeJS.Platform;
|
||||
} = {}) {
|
||||
vi.spyOn(child_process, 'execSync').mockImplementation(execSync);
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(existsResult);
|
||||
@@ -97,20 +112,25 @@ describe('ide-installer', () => {
|
||||
platform: 'linux',
|
||||
});
|
||||
await installer.install();
|
||||
expect(child_process.execSync).toHaveBeenCalledWith(
|
||||
'"code" --install-extension qwenlm.qwen-code-vscode-ide-companion --force',
|
||||
expect(child_process.spawnSync).toHaveBeenCalledWith(
|
||||
'code',
|
||||
[
|
||||
'--install-extension',
|
||||
'qwenlm.qwen-code-vscode-ide-companion',
|
||||
'--force',
|
||||
],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
ide: DetectedIde.VSCode,
|
||||
ide: IDE_DEFINITIONS.vscode,
|
||||
expectedMessage:
|
||||
'VS Code companion extension was installed successfully',
|
||||
},
|
||||
{
|
||||
ide: DetectedIde.FirebaseStudio,
|
||||
ide: IDE_DEFINITIONS.firebasestudio,
|
||||
expectedMessage:
|
||||
'Firebase Studio companion extension was installed successfully',
|
||||
},
|
||||
@@ -125,9 +145,12 @@ describe('ide-installer', () => {
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ ide: DetectedIde.VSCode, expectedErr: 'VS Code CLI not found' },
|
||||
{
|
||||
ide: DetectedIde.FirebaseStudio,
|
||||
ide: IDE_DEFINITIONS.vscode,
|
||||
expectedErr: 'VS Code CLI not found',
|
||||
},
|
||||
{
|
||||
ide: IDE_DEFINITIONS.firebasestudio,
|
||||
expectedErr: 'Firebase Studio CLI not found',
|
||||
},
|
||||
])(
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { DetectedIde, getIdeInfo, type IdeInfo } from './detect-ide.js';
|
||||
import { IDE_DEFINITIONS, type IdeInfo } from './detect-ide.js';
|
||||
import { QWEN_CODE_COMPANION_EXTENSION_NAME } from './constants.js';
|
||||
|
||||
function getVsCodeCommand(platform: NodeJS.Platform = process.platform) {
|
||||
@@ -100,14 +100,12 @@ async function findVsCodeCommand(
|
||||
|
||||
class VsCodeInstaller implements IdeInstaller {
|
||||
private vsCodeCommand: Promise<string | null>;
|
||||
private readonly ideInfo: IdeInfo;
|
||||
|
||||
constructor(
|
||||
readonly ide: DetectedIde,
|
||||
readonly ideInfo: IdeInfo,
|
||||
readonly platform = process.platform,
|
||||
) {
|
||||
this.vsCodeCommand = findVsCodeCommand(platform);
|
||||
this.ideInfo = getIdeInfo(ide);
|
||||
}
|
||||
|
||||
async install(): Promise<InstallResult> {
|
||||
@@ -119,9 +117,23 @@ class VsCodeInstaller implements IdeInstaller {
|
||||
};
|
||||
}
|
||||
|
||||
const command = `"${commandPath}" --install-extension qwenlm.qwen-code-vscode-ide-companion --force`;
|
||||
try {
|
||||
child_process.execSync(command, { stdio: 'pipe' });
|
||||
const result = child_process.spawnSync(
|
||||
commandPath,
|
||||
[
|
||||
'--install-extension',
|
||||
'qwenlm.qwen-code-vscode-ide-companion',
|
||||
'--force',
|
||||
],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to install extension: ${result.stderr?.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${this.ideInfo.displayName} companion extension was installed successfully.`,
|
||||
@@ -136,12 +148,12 @@ class VsCodeInstaller implements IdeInstaller {
|
||||
}
|
||||
|
||||
export function getIdeInstaller(
|
||||
ide: DetectedIde,
|
||||
ide: IdeInfo,
|
||||
platform = process.platform,
|
||||
): IdeInstaller | null {
|
||||
switch (ide) {
|
||||
case DetectedIde.VSCode:
|
||||
case DetectedIde.FirebaseStudio:
|
||||
switch (ide.name) {
|
||||
case IDE_DEFINITIONS.vscode.name:
|
||||
case IDE_DEFINITIONS.firebasestudio.name:
|
||||
return new VsCodeInstaller(ide, platform);
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -4,24 +4,34 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
createIdeContextStore,
|
||||
IDE_MAX_OPEN_FILES,
|
||||
IDE_MAX_SELECTED_TEXT_LENGTH,
|
||||
} from './constants.js';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { IdeContextStore } from './ideContext.js';
|
||||
import {
|
||||
type IdeContext,
|
||||
FileSchema,
|
||||
IdeContextSchema,
|
||||
} from './ideContext.js';
|
||||
type File,
|
||||
} from './types.js';
|
||||
|
||||
describe('ideContext', () => {
|
||||
describe('createIdeContextStore', () => {
|
||||
let ideContext: ReturnType<typeof createIdeContextStore>;
|
||||
let ideContextStore: IdeContextStore;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh, isolated instance for each test
|
||||
ideContext = createIdeContextStore();
|
||||
ideContextStore = new IdeContextStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return undefined initially for ide context', () => {
|
||||
expect(ideContext.getIdeContext()).toBeUndefined();
|
||||
expect(ideContextStore.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set and retrieve the ide context', () => {
|
||||
@@ -38,9 +48,9 @@ describe('ideContext', () => {
|
||||
},
|
||||
};
|
||||
|
||||
ideContext.setIdeContext(testFile);
|
||||
ideContextStore.set(testFile);
|
||||
|
||||
const activeFile = ideContext.getIdeContext();
|
||||
const activeFile = ideContextStore.get();
|
||||
expect(activeFile).toEqual(testFile);
|
||||
});
|
||||
|
||||
@@ -57,7 +67,7 @@ describe('ideContext', () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
ideContext.setIdeContext(firstFile);
|
||||
ideContextStore.set(firstFile);
|
||||
|
||||
const secondFile = {
|
||||
workspaceState: {
|
||||
@@ -71,9 +81,9 @@ describe('ideContext', () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
ideContext.setIdeContext(secondFile);
|
||||
ideContextStore.set(secondFile);
|
||||
|
||||
const activeFile = ideContext.getIdeContext();
|
||||
const activeFile = ideContextStore.get();
|
||||
expect(activeFile).toEqual(secondFile);
|
||||
});
|
||||
|
||||
@@ -90,16 +100,16 @@ describe('ideContext', () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
ideContext.setIdeContext(testFile);
|
||||
expect(ideContext.getIdeContext()).toEqual(testFile);
|
||||
ideContextStore.set(testFile);
|
||||
expect(ideContextStore.get()).toEqual(testFile);
|
||||
});
|
||||
|
||||
it('should notify subscribers when ide context changes', () => {
|
||||
const subscriber1 = vi.fn();
|
||||
const subscriber2 = vi.fn();
|
||||
|
||||
ideContext.subscribeToIdeContext(subscriber1);
|
||||
ideContext.subscribeToIdeContext(subscriber2);
|
||||
ideContextStore.subscribe(subscriber1);
|
||||
ideContextStore.subscribe(subscriber2);
|
||||
|
||||
const testFile = {
|
||||
workspaceState: {
|
||||
@@ -113,7 +123,7 @@ describe('ideContext', () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
ideContext.setIdeContext(testFile);
|
||||
ideContextStore.set(testFile);
|
||||
|
||||
expect(subscriber1).toHaveBeenCalledTimes(1);
|
||||
expect(subscriber1).toHaveBeenCalledWith(testFile);
|
||||
@@ -133,7 +143,7 @@ describe('ideContext', () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
ideContext.setIdeContext(newFile);
|
||||
ideContextStore.set(newFile);
|
||||
|
||||
expect(subscriber1).toHaveBeenCalledTimes(2);
|
||||
expect(subscriber1).toHaveBeenCalledWith(newFile);
|
||||
@@ -145,10 +155,10 @@ describe('ideContext', () => {
|
||||
const subscriber1 = vi.fn();
|
||||
const subscriber2 = vi.fn();
|
||||
|
||||
const unsubscribe1 = ideContext.subscribeToIdeContext(subscriber1);
|
||||
ideContext.subscribeToIdeContext(subscriber2);
|
||||
const unsubscribe1 = ideContextStore.subscribe(subscriber1);
|
||||
ideContextStore.subscribe(subscriber2);
|
||||
|
||||
ideContext.setIdeContext({
|
||||
ideContextStore.set({
|
||||
workspaceState: {
|
||||
openFiles: [
|
||||
{
|
||||
@@ -165,7 +175,7 @@ describe('ideContext', () => {
|
||||
|
||||
unsubscribe1();
|
||||
|
||||
ideContext.setIdeContext({
|
||||
ideContextStore.set({
|
||||
workspaceState: {
|
||||
openFiles: [
|
||||
{
|
||||
@@ -195,13 +205,152 @@ describe('ideContext', () => {
|
||||
},
|
||||
};
|
||||
|
||||
ideContext.setIdeContext(testFile);
|
||||
ideContextStore.set(testFile);
|
||||
|
||||
expect(ideContext.getIdeContext()).toEqual(testFile);
|
||||
expect(ideContextStore.get()).toEqual(testFile);
|
||||
|
||||
ideContext.clearIdeContext();
|
||||
ideContextStore.clear();
|
||||
|
||||
expect(ideContext.getIdeContext()).toBeUndefined();
|
||||
expect(ideContextStore.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set the context and notify subscribers when no workspaceState is present', () => {
|
||||
const subscriber = vi.fn();
|
||||
ideContextStore.subscribe(subscriber);
|
||||
const context: IdeContext = {};
|
||||
ideContextStore.set(context);
|
||||
expect(ideContextStore.get()).toBe(context);
|
||||
expect(subscriber).toHaveBeenCalledWith(context);
|
||||
});
|
||||
|
||||
it('should handle an empty openFiles array', () => {
|
||||
const context: IdeContext = {
|
||||
workspaceState: {
|
||||
openFiles: [],
|
||||
},
|
||||
};
|
||||
ideContextStore.set(context);
|
||||
expect(ideContextStore.get()?.workspaceState?.openFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should sort openFiles by timestamp in descending order', () => {
|
||||
const context: IdeContext = {
|
||||
workspaceState: {
|
||||
openFiles: [
|
||||
{ path: 'file1.ts', timestamp: 100, isActive: false },
|
||||
{ path: 'file2.ts', timestamp: 300, isActive: true },
|
||||
{ path: 'file3.ts', timestamp: 200, isActive: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
ideContextStore.set(context);
|
||||
const openFiles = ideContextStore.get()?.workspaceState?.openFiles;
|
||||
expect(openFiles?.[0]?.path).toBe('file2.ts');
|
||||
expect(openFiles?.[1]?.path).toBe('file3.ts');
|
||||
expect(openFiles?.[2]?.path).toBe('file1.ts');
|
||||
});
|
||||
|
||||
it('should mark only the most recent file as active and clear other active files', () => {
|
||||
const context: IdeContext = {
|
||||
workspaceState: {
|
||||
openFiles: [
|
||||
{
|
||||
path: 'file1.ts',
|
||||
timestamp: 100,
|
||||
isActive: true,
|
||||
selectedText: 'hello',
|
||||
},
|
||||
{
|
||||
path: 'file2.ts',
|
||||
timestamp: 300,
|
||||
isActive: true,
|
||||
cursor: { line: 1, character: 1 },
|
||||
selectedText: 'hello',
|
||||
},
|
||||
{
|
||||
path: 'file3.ts',
|
||||
timestamp: 200,
|
||||
isActive: false,
|
||||
selectedText: 'hello',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
ideContextStore.set(context);
|
||||
const openFiles = ideContextStore.get()?.workspaceState?.openFiles;
|
||||
expect(openFiles?.[0]?.isActive).toBe(true);
|
||||
expect(openFiles?.[0]?.cursor).toBeDefined();
|
||||
expect(openFiles?.[0]?.selectedText).toBeDefined();
|
||||
|
||||
expect(openFiles?.[1]?.isActive).toBe(false);
|
||||
expect(openFiles?.[1]?.cursor).toBeUndefined();
|
||||
expect(openFiles?.[1]?.selectedText).toBeUndefined();
|
||||
|
||||
expect(openFiles?.[2]?.isActive).toBe(false);
|
||||
expect(openFiles?.[2]?.cursor).toBeUndefined();
|
||||
expect(openFiles?.[2]?.selectedText).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should truncate selectedText if it exceeds the max length', () => {
|
||||
const longText = 'a'.repeat(IDE_MAX_SELECTED_TEXT_LENGTH + 10);
|
||||
const context: IdeContext = {
|
||||
workspaceState: {
|
||||
openFiles: [
|
||||
{
|
||||
path: 'file1.ts',
|
||||
timestamp: 100,
|
||||
isActive: true,
|
||||
selectedText: longText,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
ideContextStore.set(context);
|
||||
const selectedText =
|
||||
ideContextStore.get()?.workspaceState?.openFiles?.[0]?.selectedText;
|
||||
expect(selectedText).toHaveLength(
|
||||
IDE_MAX_SELECTED_TEXT_LENGTH + '... [TRUNCATED]'.length,
|
||||
);
|
||||
expect(selectedText?.endsWith('... [TRUNCATED]')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not truncate selectedText if it is within the max length', () => {
|
||||
const shortText = 'a'.repeat(IDE_MAX_SELECTED_TEXT_LENGTH);
|
||||
const context: IdeContext = {
|
||||
workspaceState: {
|
||||
openFiles: [
|
||||
{
|
||||
path: 'file1.ts',
|
||||
timestamp: 100,
|
||||
isActive: true,
|
||||
selectedText: shortText,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
ideContextStore.set(context);
|
||||
const selectedText =
|
||||
ideContextStore.get()?.workspaceState?.openFiles?.[0]?.selectedText;
|
||||
expect(selectedText).toBe(shortText);
|
||||
});
|
||||
|
||||
it('should truncate the openFiles list if it exceeds the max length', () => {
|
||||
const files: File[] = Array.from(
|
||||
{ length: IDE_MAX_OPEN_FILES + 5 },
|
||||
(_, i) => ({
|
||||
path: `file${i}.ts`,
|
||||
timestamp: i,
|
||||
isActive: false,
|
||||
}),
|
||||
);
|
||||
const context: IdeContext = {
|
||||
workspaceState: {
|
||||
openFiles: files,
|
||||
},
|
||||
};
|
||||
ideContextStore.set(context);
|
||||
const openFiles = ideContextStore.get()?.workspaceState?.openFiles;
|
||||
expect(openFiles).toHaveLength(IDE_MAX_OPEN_FILES);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,120 +4,24 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
IDE_MAX_OPEN_FILES,
|
||||
IDE_MAX_SELECTED_TEXT_LENGTH,
|
||||
} from './constants.js';
|
||||
import type { IdeContext } from './types.js';
|
||||
|
||||
/**
|
||||
* Zod schema for validating a file context from the IDE.
|
||||
*/
|
||||
export const FileSchema = z.object({
|
||||
path: z.string(),
|
||||
timestamp: z.number(),
|
||||
isActive: z.boolean().optional(),
|
||||
selectedText: z.string().optional(),
|
||||
cursor: z
|
||||
.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type File = z.infer<typeof FileSchema>;
|
||||
type IdeContextSubscriber = (ideContext?: IdeContext) => void;
|
||||
|
||||
export const IdeContextSchema = z.object({
|
||||
workspaceState: z
|
||||
.object({
|
||||
openFiles: z.array(FileSchema).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type IdeContext = z.infer<typeof IdeContextSchema>;
|
||||
|
||||
/**
|
||||
* Zod schema for validating the 'ide/contextUpdate' notification from the IDE.
|
||||
*/
|
||||
export const IdeContextNotificationSchema = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
method: z.literal('ide/contextUpdate'),
|
||||
params: IdeContextSchema,
|
||||
});
|
||||
|
||||
export const IdeDiffAcceptedNotificationSchema = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
method: z.literal('ide/diffAccepted'),
|
||||
params: z.object({
|
||||
filePath: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const IdeDiffClosedNotificationSchema = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
method: z.literal('ide/diffClosed'),
|
||||
params: z.object({
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const CloseDiffResponseSchema = z
|
||||
.object({
|
||||
content: z
|
||||
.array(
|
||||
z.object({
|
||||
text: z.string(),
|
||||
type: z.literal('text'),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
})
|
||||
.transform((val, ctx) => {
|
||||
try {
|
||||
const parsed = JSON.parse(val.content[0].text);
|
||||
const innerSchema = z.object({ content: z.string().optional() });
|
||||
const validationResult = innerSchema.safeParse(parsed);
|
||||
if (!validationResult.success) {
|
||||
validationResult.error.issues.forEach((issue) => ctx.addIssue(issue));
|
||||
return z.NEVER;
|
||||
}
|
||||
return validationResult.data;
|
||||
} catch (_) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid JSON in text content',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
export type DiffUpdateResult =
|
||||
| {
|
||||
status: 'accepted';
|
||||
content?: string;
|
||||
}
|
||||
| {
|
||||
status: 'rejected';
|
||||
content: undefined;
|
||||
};
|
||||
|
||||
type IdeContextSubscriber = (ideContext: IdeContext | undefined) => void;
|
||||
|
||||
/**
|
||||
* Creates a new store for managing the IDE's context.
|
||||
* This factory function encapsulates the state and logic, allowing for the creation
|
||||
* of isolated instances, which is particularly useful for testing.
|
||||
*
|
||||
* @returns An object with methods to interact with the IDE context.
|
||||
*/
|
||||
export function createIdeContextStore() {
|
||||
let ideContextState: IdeContext | undefined = undefined;
|
||||
const subscribers = new Set<IdeContextSubscriber>();
|
||||
export class IdeContextStore {
|
||||
private ideContextState?: IdeContext;
|
||||
private readonly subscribers = new Set<IdeContextSubscriber>();
|
||||
|
||||
/**
|
||||
* Notifies all registered subscribers about the current IDE context.
|
||||
*/
|
||||
function notifySubscribers(): void {
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber(ideContextState);
|
||||
private notifySubscribers(): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(this.ideContextState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,25 +29,76 @@ export function createIdeContextStore() {
|
||||
* Sets the IDE context and notifies all registered subscribers of the change.
|
||||
* @param newIdeContext The new IDE context from the IDE.
|
||||
*/
|
||||
function setIdeContext(newIdeContext: IdeContext): void {
|
||||
ideContextState = newIdeContext;
|
||||
notifySubscribers();
|
||||
set(newIdeContext: IdeContext): void {
|
||||
const { workspaceState } = newIdeContext;
|
||||
if (!workspaceState) {
|
||||
this.ideContextState = newIdeContext;
|
||||
this.notifySubscribers();
|
||||
return;
|
||||
}
|
||||
|
||||
const { openFiles } = workspaceState;
|
||||
|
||||
if (openFiles && openFiles.length > 0) {
|
||||
// Sort by timestamp descending (newest first)
|
||||
openFiles.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
// The most recent file is now at index 0.
|
||||
const mostRecentFile = openFiles[0];
|
||||
|
||||
// If the most recent file is not active, then no file is active.
|
||||
if (!mostRecentFile.isActive) {
|
||||
openFiles.forEach((file) => {
|
||||
file.isActive = false;
|
||||
file.cursor = undefined;
|
||||
file.selectedText = undefined;
|
||||
});
|
||||
} else {
|
||||
// The most recent file is active. Ensure it's the only one.
|
||||
openFiles.forEach((file, index: number) => {
|
||||
if (index !== 0) {
|
||||
file.isActive = false;
|
||||
file.cursor = undefined;
|
||||
file.selectedText = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// Truncate selected text in the active file
|
||||
if (
|
||||
mostRecentFile.selectedText &&
|
||||
mostRecentFile.selectedText.length > IDE_MAX_SELECTED_TEXT_LENGTH
|
||||
) {
|
||||
mostRecentFile.selectedText =
|
||||
mostRecentFile.selectedText.substring(
|
||||
0,
|
||||
IDE_MAX_SELECTED_TEXT_LENGTH,
|
||||
) + '... [TRUNCATED]';
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate files list
|
||||
if (openFiles.length > IDE_MAX_OPEN_FILES) {
|
||||
workspaceState.openFiles = openFiles.slice(0, IDE_MAX_OPEN_FILES);
|
||||
}
|
||||
}
|
||||
this.ideContextState = newIdeContext;
|
||||
this.notifySubscribers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the IDE context and notifies all registered subscribers of the change.
|
||||
*/
|
||||
function clearIdeContext(): void {
|
||||
ideContextState = undefined;
|
||||
notifySubscribers();
|
||||
clear(): void {
|
||||
this.ideContextState = undefined;
|
||||
this.notifySubscribers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current IDE context.
|
||||
* @returns The `IdeContext` object if a file is active; otherwise, `undefined`.
|
||||
*/
|
||||
function getIdeContext(): IdeContext | undefined {
|
||||
return ideContextState;
|
||||
get(): IdeContext | undefined {
|
||||
return this.ideContextState;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,22 +110,15 @@ export function createIdeContextStore() {
|
||||
* @param subscriber The function to be called when the IDE context changes.
|
||||
* @returns A function that, when called, will unsubscribe the provided subscriber.
|
||||
*/
|
||||
function subscribeToIdeContext(subscriber: IdeContextSubscriber): () => void {
|
||||
subscribers.add(subscriber);
|
||||
subscribe(subscriber: IdeContextSubscriber): () => void {
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
subscribers.delete(subscriber);
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
setIdeContext,
|
||||
getIdeContext,
|
||||
subscribeToIdeContext,
|
||||
clearIdeContext,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The default, shared instance of the IDE context store for the application.
|
||||
*/
|
||||
export const ideContext = createIdeContextStore();
|
||||
export const ideContextStore = new IdeContextStore();
|
||||
|
||||
@@ -44,11 +44,18 @@ async function getProcessInfo(pid: number): Promise<{
|
||||
ParentProcessId = 0,
|
||||
CommandLine = '',
|
||||
} = JSON.parse(output);
|
||||
return { parentPid: ParentProcessId, name: Name, command: CommandLine };
|
||||
return {
|
||||
parentPid: ParentProcessId,
|
||||
name: Name,
|
||||
command: CommandLine ?? '',
|
||||
};
|
||||
} else {
|
||||
const command = `ps -o ppid=,command= -p ${pid}`;
|
||||
const { stdout } = await execAsync(command);
|
||||
const trimmedStdout = stdout.trim();
|
||||
if (!trimmedStdout) {
|
||||
return { parentPid: 0, name: '', command: '' };
|
||||
}
|
||||
const ppidString = trimmedStdout.split(/\s+/)[0];
|
||||
const parentPid = parseInt(ppidString, 10);
|
||||
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
|
||||
|
||||
148
packages/core/src/ide/types.ts
Normal file
148
packages/core/src/ide/types.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* A file that is open in the IDE.
|
||||
*/
|
||||
export const FileSchema = z.object({
|
||||
/**
|
||||
* The absolute path to the file.
|
||||
*/
|
||||
path: z.string(),
|
||||
/**
|
||||
* The unix timestamp of when the file was last focused.
|
||||
*/
|
||||
timestamp: z.number(),
|
||||
/**
|
||||
* Whether the file is the currently active file. Only one file can be active at a time.
|
||||
*/
|
||||
isActive: z.boolean().optional(),
|
||||
/**
|
||||
* The text that is currently selected in the active file.
|
||||
*/
|
||||
selectedText: z.string().optional(),
|
||||
/**
|
||||
* The cursor position in the active file.
|
||||
*/
|
||||
cursor: z
|
||||
.object({
|
||||
/**
|
||||
* The 1-based line number.
|
||||
*/
|
||||
line: z.number(),
|
||||
/**
|
||||
* The 1-based character offset.
|
||||
*/
|
||||
character: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type File = z.infer<typeof FileSchema>;
|
||||
|
||||
/**
|
||||
* The context of the IDE.
|
||||
*/
|
||||
export const IdeContextSchema = z.object({
|
||||
workspaceState: z
|
||||
.object({
|
||||
/**
|
||||
* The list of files that are currently open.
|
||||
*/
|
||||
openFiles: z.array(FileSchema).optional(),
|
||||
/**
|
||||
* Whether the workspace is trusted.
|
||||
*/
|
||||
isTrusted: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type IdeContext = z.infer<typeof IdeContextSchema>;
|
||||
|
||||
/**
|
||||
* A notification that the IDE context has been updated.
|
||||
*/
|
||||
export const IdeContextNotificationSchema = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
method: z.literal('ide/contextUpdate'),
|
||||
params: IdeContextSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* A notification that a diff has been accepted in the IDE.
|
||||
*/
|
||||
export const IdeDiffAcceptedNotificationSchema = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
method: z.literal('ide/diffAccepted'),
|
||||
params: z.object({
|
||||
/**
|
||||
* The absolute path to the file that was diffed.
|
||||
*/
|
||||
filePath: z.string(),
|
||||
/**
|
||||
* The full content of the file after the diff was accepted, which includes any manual edits the user may have made.
|
||||
*/
|
||||
content: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* A notification that a diff has been rejected in the IDE.
|
||||
*/
|
||||
export const IdeDiffRejectedNotificationSchema = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
method: z.literal('ide/diffRejected'),
|
||||
params: z.object({
|
||||
/**
|
||||
* The absolute path to the file that was diffed.
|
||||
*/
|
||||
filePath: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* This is defineded for backwards compatability only. Newer extension versions
|
||||
* will only send IdeDiffRejectedNotificationSchema.
|
||||
*
|
||||
* A notification that a diff has been closed in the IDE.
|
||||
*/
|
||||
export const IdeDiffClosedNotificationSchema = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
method: z.literal('ide/diffClosed'),
|
||||
params: z.object({
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The request to open a diff view in the IDE.
|
||||
*/
|
||||
export const OpenDiffRequestSchema = z.object({
|
||||
/**
|
||||
* The absolute path to the file to be diffed.
|
||||
*/
|
||||
filePath: z.string(),
|
||||
/**
|
||||
* The proposed new content for the file.
|
||||
*/
|
||||
newContent: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* The request to close a diff view in the IDE.
|
||||
*/
|
||||
export const CloseDiffRequestSchema = z.object({
|
||||
/**
|
||||
* The absolute path to the file to be diffed.
|
||||
*/
|
||||
filePath: z.string(),
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
suppressNotification: z.boolean().optional(),
|
||||
});
|
||||
Reference in New Issue
Block a user