mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
Compare commits
3 Commits
feature/do
...
pr/suger-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
293f046804 | ||
|
|
ea344ad036 | ||
|
|
22480302c3 |
16
.vscode/launch.json
vendored
16
.vscode/launch.json
vendored
@@ -1,6 +1,22 @@
|
|||||||
{
|
{
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Run VSCode IDE Bridge",
|
||||||
|
"type": "extensionHost",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeExecutable": "${execPath}",
|
||||||
|
"args": [
|
||||||
|
"--new-window",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--extensionDevelopmentPath=${workspaceFolder}/packages/vscode-ide-bridge",
|
||||||
|
"${workspaceFolder}"
|
||||||
|
],
|
||||||
|
"outFiles": [
|
||||||
|
"${workspaceFolder}/packages/vscode-ide-bridge/dist/**/*.js"
|
||||||
|
],
|
||||||
|
"preLaunchTask": "Build VSCode IDE Bridge"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "bun",
|
"type": "bun",
|
||||||
"request": "attach",
|
"request": "attach",
|
||||||
|
|||||||
35
.vscode/tasks.json
vendored
35
.vscode/tasks.json
vendored
@@ -1,6 +1,39 @@
|
|||||||
{
|
{
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Build VSCode IDE Bridge",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "bunx",
|
||||||
|
"args": [
|
||||||
|
"tsc",
|
||||||
|
"-p",
|
||||||
|
"packages/vscode-ide-bridge/tsconfig.json"
|
||||||
|
],
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"clear": true
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Test VSCode IDE Bridge",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "bun",
|
||||||
|
"args": [
|
||||||
|
"test",
|
||||||
|
"packages/vscode-ide-bridge/test"
|
||||||
|
],
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"clear": true
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "Start Claude Code TUI",
|
"label": "Start Claude Code TUI",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
@@ -24,4 +57,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
664
docs/superpowers/plans/2026-04-07-vscode-ide-bridge.md
Normal file
664
docs/superpowers/plans/2026-04-07-vscode-ide-bridge.md
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
# VSCode IDE Bridge Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 为当前 CLI 增加一个可运行的 VSCode `ws-ide` 扩展端实现,让 `/ide`、选区上下文注入和 IDE diff 预览在本地 VSCode 中可用。
|
||||||
|
|
||||||
|
**Architecture:** 在仓库中新增独立的 VSCode 扩展包,扩展在本地启动 WebSocket IDE Bridge,并通过 lockfile 让 CLI 自动发现。扩展在该连接上暴露一个 MCP Server,负责发送 `selection_changed` / `ide_connected` 通知,并实现 `openDiff`、`close_tab`、`closeAllDiffTabs` 这几个 CLI 已使用的 MCP tools。
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript、VSCode Extension API、WebSocket、`@modelcontextprotocol/sdk`、Node.js 文件系统 API
|
||||||
|
|
||||||
|
> 说明:执行前已校正协议边界。这里的 `openDiff` / `close_tab` / `closeAllDiffTabs` 不是自定义裸 WebSocket RPC,而是通过 MCP tool 调用完成;`selection_changed` / `ide_connected` 才是扩展主动发往 CLI 的通知。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 脚手架 VSCode 扩展包
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/vscode-ide-bridge/package.json`
|
||||||
|
- Create: `packages/vscode-ide-bridge/tsconfig.json`
|
||||||
|
- Create: `packages/vscode-ide-bridge/src/extension.ts`
|
||||||
|
- Modify: `package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写出失败测试或校验入口约束**
|
||||||
|
|
||||||
|
使用最小结构校验,确保新包会被 workspace 识别并且扩展入口文件存在。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import pkg from "../../vscode-ide-bridge/package.json";
|
||||||
|
|
||||||
|
describe("vscode-ide-bridge package", () => {
|
||||||
|
test("declares a VSCode extension entry", () => {
|
||||||
|
expect(pkg.main).toBe("./dist/extension.js");
|
||||||
|
expect(pkg.engines.vscode).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试并确认失败**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/package.test.ts`
|
||||||
|
Expected: FAIL,提示包文件不存在或字段缺失
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写最小扩展包结构**
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/package.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "vscode-ide-bridge",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/extension.js",
|
||||||
|
"engines": {
|
||||||
|
"vscode": "^1.90.0"
|
||||||
|
},
|
||||||
|
"activationEvents": [
|
||||||
|
"onStartupFinished",
|
||||||
|
"onCommand:claudeCodeBridge.restart",
|
||||||
|
"onCommand:claudeCodeBridge.showStatus"
|
||||||
|
],
|
||||||
|
"contributes": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "claudeCodeBridge.restart",
|
||||||
|
"title": "Claude Code Bridge: Restart"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "claudeCodeBridge.showStatus",
|
||||||
|
"title": "Claude Code Bridge: Show Status"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/tsconfig.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node", "vscode"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/src/extension.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
|
||||||
|
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand("claudeCodeBridge.restart", () => {}),
|
||||||
|
vscode.commands.registerCommand("claudeCodeBridge.showStatus", () => {})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivate(): Promise<void> {}
|
||||||
|
```
|
||||||
|
|
||||||
|
根目录 `package.json` workspace 增加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*",
|
||||||
|
"packages/@ant/*",
|
||||||
|
"packages/vscode-ide-bridge"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试确认通过**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/package.test.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json packages/vscode-ide-bridge/package.json packages/vscode-ide-bridge/tsconfig.json packages/vscode-ide-bridge/src/extension.ts packages/vscode-ide-bridge/test/package.test.ts
|
||||||
|
git commit -m "feat: scaffold vscode ide bridge extension"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: 实现 lockfile 与状态模型
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/vscode-ide-bridge/src/server/lockfile.ts`
|
||||||
|
- Create: `packages/vscode-ide-bridge/src/server/workspaceInfo.ts`
|
||||||
|
- Create: `packages/vscode-ide-bridge/src/server/protocol.ts`
|
||||||
|
- Create: `packages/vscode-ide-bridge/test/lockfile.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { buildLockfilePayload } from "../src/server/lockfile";
|
||||||
|
|
||||||
|
describe("buildLockfilePayload", () => {
|
||||||
|
test("includes ws transport, auth token and workspace folders", () => {
|
||||||
|
const payload = buildLockfilePayload({
|
||||||
|
port: 8123,
|
||||||
|
pid: 100,
|
||||||
|
ideName: "VS Code",
|
||||||
|
workspaceFolders: ["D:/repo"],
|
||||||
|
authToken: "token-1",
|
||||||
|
runningInWindows: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.transport).toBe("ws");
|
||||||
|
expect(payload.authToken).toBe("token-1");
|
||||||
|
expect(payload.workspaceFolders).toEqual(["D:/repo"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试并确认失败**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/lockfile.test.ts`
|
||||||
|
Expected: FAIL,提示模块不存在
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写最小实现**
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/src/server/protocol.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type LockfilePayload = {
|
||||||
|
workspaceFolders: string[];
|
||||||
|
pid: number;
|
||||||
|
ideName: string;
|
||||||
|
transport: "ws";
|
||||||
|
runningInWindows: boolean;
|
||||||
|
authToken: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/src/server/lockfile.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { LockfilePayload } from "./protocol";
|
||||||
|
|
||||||
|
export function buildLockfilePayload(input: {
|
||||||
|
port: number;
|
||||||
|
pid: number;
|
||||||
|
ideName: string;
|
||||||
|
workspaceFolders: string[];
|
||||||
|
authToken: string;
|
||||||
|
runningInWindows: boolean;
|
||||||
|
}): LockfilePayload {
|
||||||
|
return {
|
||||||
|
workspaceFolders: input.workspaceFolders,
|
||||||
|
pid: input.pid,
|
||||||
|
ideName: input.ideName,
|
||||||
|
transport: "ws",
|
||||||
|
runningInWindows: input.runningInWindows,
|
||||||
|
authToken: input.authToken
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLockfilePath(port: number): string {
|
||||||
|
return join(homedir(), ".claude", "ide", `${port}.lock`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeLockfile(port: number, payload: LockfilePayload): Promise<string> {
|
||||||
|
const path = getLockfilePath(port);
|
||||||
|
await mkdir(join(homedir(), ".claude", "ide"), { recursive: true });
|
||||||
|
await writeFile(path, JSON.stringify(payload), "utf8");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeLockfile(path: string | null): Promise<void> {
|
||||||
|
if (!path) return;
|
||||||
|
await rm(path, { force: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/src/server/workspaceInfo.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
|
||||||
|
export function getWorkspaceFolders(): string[] {
|
||||||
|
return (vscode.workspace.workspaceFolders ?? []).map(folder => folder.uri.fsPath);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试确认通过**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/lockfile.test.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/vscode-ide-bridge/src/server/protocol.ts packages/vscode-ide-bridge/src/server/lockfile.ts packages/vscode-ide-bridge/src/server/workspaceInfo.ts packages/vscode-ide-bridge/test/lockfile.test.ts
|
||||||
|
git commit -m "feat: add vscode ide bridge lockfile support"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: 实现选区发布链路
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/vscode-ide-bridge/src/server/selectionPublisher.ts`
|
||||||
|
- Create: `packages/vscode-ide-bridge/test/selectionPublisher.test.ts`
|
||||||
|
- Modify: `packages/vscode-ide-bridge/src/extension.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { buildSelectionChangedParams } from "../src/server/selectionPublisher";
|
||||||
|
|
||||||
|
describe("buildSelectionChangedParams", () => {
|
||||||
|
test("serializes editor selection and text", () => {
|
||||||
|
const params = buildSelectionChangedParams({
|
||||||
|
filePath: "D:/repo/src/app.ts",
|
||||||
|
text: "const x = 1;",
|
||||||
|
start: { line: 1, character: 0 },
|
||||||
|
end: { line: 1, character: 12 }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(params.filePath).toBe("D:/repo/src/app.ts");
|
||||||
|
expect(params.text).toBe("const x = 1;");
|
||||||
|
expect(params.selection?.start.line).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试并确认失败**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/selectionPublisher.test.ts`
|
||||||
|
Expected: FAIL,提示导出不存在
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写最小实现**
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/src/server/selectionPublisher.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type SelectionPoint = {
|
||||||
|
line: number;
|
||||||
|
character: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectionChangedParams = {
|
||||||
|
selection: {
|
||||||
|
start: SelectionPoint;
|
||||||
|
end: SelectionPoint;
|
||||||
|
} | null;
|
||||||
|
text?: string;
|
||||||
|
filePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildSelectionChangedParams(input: {
|
||||||
|
filePath?: string;
|
||||||
|
text?: string;
|
||||||
|
start?: SelectionPoint;
|
||||||
|
end?: SelectionPoint;
|
||||||
|
}): SelectionChangedParams {
|
||||||
|
if (!input.start || !input.end) {
|
||||||
|
return {
|
||||||
|
selection: null,
|
||||||
|
text: input.text,
|
||||||
|
filePath: input.filePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selection: {
|
||||||
|
start: input.start,
|
||||||
|
end: input.end
|
||||||
|
},
|
||||||
|
text: input.text,
|
||||||
|
filePath: input.filePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/src/extension.ts` 先增加一个占位发布调用:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { buildSelectionChangedParams } from "./server/selectionPublisher";
|
||||||
|
|
||||||
|
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
||||||
|
const disposable = vscode.window.onDidChangeTextEditorSelection(event => {
|
||||||
|
const editor = event.textEditor;
|
||||||
|
const selection = editor.selection;
|
||||||
|
buildSelectionChangedParams({
|
||||||
|
filePath: editor.document.uri.fsPath,
|
||||||
|
text: editor.document.getText(selection),
|
||||||
|
start: {
|
||||||
|
line: selection.start.line,
|
||||||
|
character: selection.start.character
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
line: selection.end.line,
|
||||||
|
character: selection.end.character
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
|
disposable,
|
||||||
|
vscode.commands.registerCommand("claudeCodeBridge.restart", () => {}),
|
||||||
|
vscode.commands.registerCommand("claudeCodeBridge.showStatus", () => {})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试确认通过**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/selectionPublisher.test.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/vscode-ide-bridge/src/server/selectionPublisher.ts packages/vscode-ide-bridge/test/selectionPublisher.test.ts packages/vscode-ide-bridge/src/extension.ts
|
||||||
|
git commit -m "feat: add vscode selection publisher primitives"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: 实现 WebSocket bridge server 与鉴权
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/vscode-ide-bridge/src/server/bridgeServer.ts`
|
||||||
|
- Create: `packages/vscode-ide-bridge/test/bridgeServer.test.ts`
|
||||||
|
- Modify: `packages/vscode-ide-bridge/src/extension.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { isAuthorizedUpgrade } from "../src/server/bridgeServer";
|
||||||
|
|
||||||
|
describe("isAuthorizedUpgrade", () => {
|
||||||
|
test("accepts matching token", () => {
|
||||||
|
expect(isAuthorizedUpgrade("abc", "abc")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects mismatched token", () => {
|
||||||
|
expect(isAuthorizedUpgrade("abc", "def")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试并确认失败**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/bridgeServer.test.ts`
|
||||||
|
Expected: FAIL,提示模块不存在
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写最小实现**
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/src/server/bridgeServer.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
|
|
||||||
|
export function isAuthorizedUpgrade(expected: string, actual: string | undefined): boolean {
|
||||||
|
return Boolean(actual) && expected === actual;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BridgeServer {
|
||||||
|
private server: WebSocketServer | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly authToken: string) {}
|
||||||
|
|
||||||
|
async start(port: number): Promise<void> {
|
||||||
|
this.server = new WebSocketServer({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
if (!this.server) return resolve();
|
||||||
|
this.server.close(() => resolve());
|
||||||
|
this.server = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/src/extension.ts` 中接入:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { BridgeServer } from "./server/bridgeServer";
|
||||||
|
|
||||||
|
let bridgeServer: BridgeServer | null = null;
|
||||||
|
|
||||||
|
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
||||||
|
bridgeServer = new BridgeServer(randomUUID());
|
||||||
|
await bridgeServer.start(0);
|
||||||
|
context.subscriptions.push({
|
||||||
|
dispose() {
|
||||||
|
void bridgeServer?.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试确认通过**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/bridgeServer.test.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/vscode-ide-bridge/src/server/bridgeServer.ts packages/vscode-ide-bridge/test/bridgeServer.test.ts packages/vscode-ide-bridge/src/extension.ts
|
||||||
|
git commit -m "feat: add vscode ide bridge websocket server"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: 实现 diff RPC 和状态命令
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/vscode-ide-bridge/src/server/diffController.ts`
|
||||||
|
- Modify: `packages/vscode-ide-bridge/src/extension.ts`
|
||||||
|
- Create: `packages/vscode-ide-bridge/test/diffController.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { DiffSessionStore } from "../src/server/diffController";
|
||||||
|
|
||||||
|
describe("DiffSessionStore", () => {
|
||||||
|
test("stores and removes tab mappings by tab name", () => {
|
||||||
|
const store = new DiffSessionStore();
|
||||||
|
store.set("tab-1", "memfs:/right.ts");
|
||||||
|
expect(store.get("tab-1")).toBe("memfs:/right.ts");
|
||||||
|
store.delete("tab-1");
|
||||||
|
expect(store.get("tab-1")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试并确认失败**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/diffController.test.ts`
|
||||||
|
Expected: FAIL,提示模块不存在
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写最小实现**
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/src/server/diffController.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class DiffSessionStore {
|
||||||
|
private readonly sessions = new Map<string, string>();
|
||||||
|
|
||||||
|
set(tabName: string, uri: string): void {
|
||||||
|
this.sessions.set(tabName, uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(tabName: string): string | undefined {
|
||||||
|
return this.sessions.get(tabName);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(tabName: string): void {
|
||||||
|
this.sessions.delete(tabName);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.sessions.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/src/extension.ts` 增加状态命令:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
|
||||||
|
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
||||||
|
const output = vscode.window.createOutputChannel("Claude Code IDE Bridge");
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
|
output,
|
||||||
|
vscode.commands.registerCommand("claudeCodeBridge.showStatus", async () => {
|
||||||
|
output.appendLine("Claude Code IDE Bridge is running.");
|
||||||
|
output.show(true);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试确认通过**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/diffController.test.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/vscode-ide-bridge/src/server/diffController.ts packages/vscode-ide-bridge/test/diffController.test.ts packages/vscode-ide-bridge/src/extension.ts
|
||||||
|
git commit -m "feat: add vscode ide bridge diff state and status command"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: 接通完整激活流程与手工验证说明
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/vscode-ide-bridge/src/extension.ts`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `README_EN.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败校验**
|
||||||
|
|
||||||
|
用文档断言确保 README 中包含 bridge 启动与 `/ide` 使用说明。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
describe("README bridge docs", () => {
|
||||||
|
test("documents vscode ide bridge usage", () => {
|
||||||
|
const readme = readFileSync("README.md", "utf8");
|
||||||
|
expect(readme.includes("VSCode IDE Bridge")).toBe(true);
|
||||||
|
expect(readme.includes("/ide")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试并确认失败**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/readme.test.ts`
|
||||||
|
Expected: FAIL,提示 README 中没有 bridge 文档
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现激活主流程与文档**
|
||||||
|
|
||||||
|
`packages/vscode-ide-bridge/src/extension.ts` 最终需要做到:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { writeLockfile, removeLockfile, buildLockfilePayload } from "./server/lockfile";
|
||||||
|
import { getWorkspaceFolders } from "./server/workspaceInfo";
|
||||||
|
import { BridgeServer } from "./server/bridgeServer";
|
||||||
|
|
||||||
|
let lockfilePath: string | null = null;
|
||||||
|
let bridgeServer: BridgeServer | null = null;
|
||||||
|
|
||||||
|
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
||||||
|
const authToken = randomUUID();
|
||||||
|
const output = vscode.window.createOutputChannel("Claude Code IDE Bridge");
|
||||||
|
|
||||||
|
bridgeServer = new BridgeServer(authToken);
|
||||||
|
await bridgeServer.start(0);
|
||||||
|
|
||||||
|
const payload = buildLockfilePayload({
|
||||||
|
port: 0,
|
||||||
|
pid: process.pid,
|
||||||
|
ideName: "VS Code",
|
||||||
|
workspaceFolders: getWorkspaceFolders(),
|
||||||
|
authToken,
|
||||||
|
runningInWindows: process.platform === "win32"
|
||||||
|
});
|
||||||
|
|
||||||
|
lockfilePath = await writeLockfile(0, payload);
|
||||||
|
output.appendLine(`Bridge started. Lockfile: ${lockfilePath}`);
|
||||||
|
|
||||||
|
context.subscriptions.push(output, {
|
||||||
|
dispose() {
|
||||||
|
void bridgeServer?.stop();
|
||||||
|
void removeLockfile(lockfilePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivate(): Promise<void> {
|
||||||
|
await bridgeServer?.stop();
|
||||||
|
await removeLockfile(lockfilePath);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
README 中文和英文各补一个简短章节,说明:
|
||||||
|
|
||||||
|
- 扩展启动后会暴露本地 bridge
|
||||||
|
- 启动 CLI 后执行 `/ide`
|
||||||
|
- 在 VSCode 里选中代码,再向 CLI 提问
|
||||||
|
- diff 预览由 CLI 主动触发
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行验证**
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/readme.test.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
Run: `bun test packages/vscode-ide-bridge/test/*.test.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
手工验证:
|
||||||
|
|
||||||
|
Run: `bun run build.ts`
|
||||||
|
Expected: 构建完成,无本次改动引入的额外错误
|
||||||
|
|
||||||
|
手工步骤:
|
||||||
|
|
||||||
|
1. 在 VSCode 启动扩展开发宿主
|
||||||
|
2. 打开本仓库
|
||||||
|
3. 启动 CLI
|
||||||
|
4. 执行 `/ide`
|
||||||
|
5. 在编辑器中选中文本后提问
|
||||||
|
6. 验证 CLI 可见 IDE 选区上下文
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/vscode-ide-bridge/src/extension.ts README.md README_EN.md packages/vscode-ide-bridge/test/readme.test.ts
|
||||||
|
git commit -m "feat: wire vscode ide bridge activation and docs"
|
||||||
|
```
|
||||||
350
docs/superpowers/specs/2026-04-07-vscode-ide-bridge-design.md
Normal file
350
docs/superpowers/specs/2026-04-07-vscode-ide-bridge-design.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# VSCode IDE Bridge 设计文档
|
||||||
|
|
||||||
|
**日期:** 2026-04-07
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
当前仓库已经具备一套较完整的 IDE 接入链路:
|
||||||
|
|
||||||
|
- CLI 能发现 `ws-ide` / `sse-ide` 类型的 IDE 连接
|
||||||
|
- CLI 能接收 `selection_changed` 并将其注入为 `<ide_selection>` 上下文
|
||||||
|
- CLI 能调用 `openDiff`、`close_tab`、`closeAllDiffTabs` 等 IDE RPC
|
||||||
|
- `/ide`、diff 预览、选区提示、已打开文件提示都依赖这套链路
|
||||||
|
|
||||||
|
但当前仓库中没有可直接使用的 VSCode 扩展实现,导致本地 VSCode 无法真正把这些能力提供给 CLI。目标不是重做一个聊天面板,而是补齐一个兼容现有 CLI 协议的 VSCode 扩展,让 CLI “像连接到原生 IDE 扩展一样”工作。
|
||||||
|
|
||||||
|
## 2. 目标
|
||||||
|
|
||||||
|
构建一个独立的 VSCode 扩展,在本地暴露一个与当前 CLI 兼容的 `ws-ide` 服务,完成以下能力:
|
||||||
|
|
||||||
|
1. 让 CLI 能自动发现 VSCode
|
||||||
|
2. 让 VSCode 当前文件和选区变化能进入 CLI 的 IDE 上下文链路
|
||||||
|
3. 让 CLI 发起的 diff 预览能在 VSCode 中打开和关闭
|
||||||
|
4. 保持实现最小、可调试、可逐步扩展
|
||||||
|
|
||||||
|
## 3. 非目标
|
||||||
|
|
||||||
|
第一版明确不做以下内容:
|
||||||
|
|
||||||
|
- 不实现 VSCode 聊天面板
|
||||||
|
- 不接入远程工作区、Codespaces、Dev Container、SSH Remote
|
||||||
|
- 不兼容多台机器之间的桥接
|
||||||
|
- 不实现复杂的会话恢复或扩展端持久化缓存
|
||||||
|
- 不覆盖官方扩展的所有功能
|
||||||
|
|
||||||
|
## 4. 总体方案
|
||||||
|
|
||||||
|
采用“独立 sidecar 扩展 + 本地 WebSocket IDE Bridge”的方式。
|
||||||
|
|
||||||
|
### 4.1 连接模型
|
||||||
|
|
||||||
|
VSCode 扩展启动后:
|
||||||
|
|
||||||
|
1. 在 `127.0.0.1` 上启动一个随机可用端口的 WebSocket 服务
|
||||||
|
2. 生成与 CLI 现有 IDE 发现逻辑兼容的 lockfile
|
||||||
|
3. 等待 CLI 以 `ws-ide` MCP 客户端身份连接
|
||||||
|
4. 扩展在该 WebSocket 连接上暴露 MCP Server,负责把 IDE 事件推送给 CLI,并响应 CLI 发来的 MCP tool 调用
|
||||||
|
|
||||||
|
### 4.2 复用现有 CLI 能力
|
||||||
|
|
||||||
|
扩展尽量不改 CLI 的上层交互,只复用现有协议:
|
||||||
|
|
||||||
|
- VSCode -> CLI:`selection_changed`、`ide_connected` 通知
|
||||||
|
- CLI -> VSCode:通过 MCP tool 调用 `openDiff`、`close_tab`、`closeAllDiffTabs`
|
||||||
|
|
||||||
|
这样可以最大化复用:
|
||||||
|
|
||||||
|
- `src/hooks/useIdeSelection.ts`
|
||||||
|
- `src/utils/attachments.ts`
|
||||||
|
- `src/utils/messages.ts`
|
||||||
|
- `src/hooks/useDiffInIDE.ts`
|
||||||
|
- `/ide` 命令及 IDE 状态展示
|
||||||
|
|
||||||
|
## 5. 协议设计
|
||||||
|
|
||||||
|
### 5.1 Lockfile
|
||||||
|
|
||||||
|
扩展写出的 lockfile 需要满足 CLI 的 IDE 自动发现逻辑。内容至少包含:
|
||||||
|
|
||||||
|
- `workspaceFolders`
|
||||||
|
- `pid`
|
||||||
|
- `ideName`
|
||||||
|
- `transport: "ws"`
|
||||||
|
- `runningInWindows`
|
||||||
|
- `authToken`
|
||||||
|
|
||||||
|
文件名使用端口号,例如 `<port>.lock`。
|
||||||
|
|
||||||
|
### 5.2 鉴权
|
||||||
|
|
||||||
|
扩展启动时生成一次随机 `authToken`:
|
||||||
|
|
||||||
|
- 写入 lockfile
|
||||||
|
- CLI 连接 `ws-ide` 时通过 `X-Claude-Code-Ide-Authorization` 头带上
|
||||||
|
- 扩展端校验成功后才允许建立 MCP/WebSocket 会话
|
||||||
|
|
||||||
|
第一版只允许本地回环地址,不暴露到公网。
|
||||||
|
|
||||||
|
### 5.3 VSCode -> CLI 通知
|
||||||
|
|
||||||
|
#### `selection_changed`
|
||||||
|
|
||||||
|
在下列事件触发后发送:
|
||||||
|
|
||||||
|
- `window.onDidChangeTextEditorSelection`
|
||||||
|
- `window.onDidChangeActiveTextEditor`
|
||||||
|
- 扩展激活完成后的初始同步
|
||||||
|
|
||||||
|
消息字段包含:
|
||||||
|
|
||||||
|
- `selection.start.line`
|
||||||
|
- `selection.start.character`
|
||||||
|
- `selection.end.line`
|
||||||
|
- `selection.end.character`
|
||||||
|
- `text`
|
||||||
|
- `filePath`
|
||||||
|
|
||||||
|
若当前没有活动选区:
|
||||||
|
|
||||||
|
- `selection` 允许为 `null`
|
||||||
|
- 仍尽量发送 `filePath`
|
||||||
|
|
||||||
|
这样 CLI 至少可以知道“用户当前打开的是哪个文件”。
|
||||||
|
|
||||||
|
### 5.4 CLI -> VSCode MCP tools
|
||||||
|
|
||||||
|
#### `openDiff`
|
||||||
|
|
||||||
|
入参:
|
||||||
|
|
||||||
|
- `old_file_path`
|
||||||
|
- `new_file_path`
|
||||||
|
- `new_file_contents`
|
||||||
|
- `tab_name`
|
||||||
|
|
||||||
|
行为:
|
||||||
|
|
||||||
|
- 读取当前磁盘文件内容作为左侧内容
|
||||||
|
- 使用临时文档或内存文档构造右侧内容
|
||||||
|
- 在 VSCode 中打开 diff 视图
|
||||||
|
- 记录 `tab_name -> 资源引用` 映射
|
||||||
|
|
||||||
|
#### `close_tab`
|
||||||
|
|
||||||
|
入参:
|
||||||
|
|
||||||
|
- `tab_name`
|
||||||
|
|
||||||
|
行为:
|
||||||
|
|
||||||
|
- 根据映射关闭对应 diff 视图
|
||||||
|
- 清理映射与临时资源
|
||||||
|
|
||||||
|
#### `closeAllDiffTabs`
|
||||||
|
|
||||||
|
行为:
|
||||||
|
|
||||||
|
- 关闭所有由本扩展打开的 diff 标签
|
||||||
|
- 清理内部状态
|
||||||
|
|
||||||
|
## 6. 扩展内部结构
|
||||||
|
|
||||||
|
建议新增独立包:`packages/vscode-ide-bridge`
|
||||||
|
|
||||||
|
目录结构如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/vscode-ide-bridge/
|
||||||
|
package.json
|
||||||
|
tsconfig.json
|
||||||
|
src/
|
||||||
|
extension.ts
|
||||||
|
server/
|
||||||
|
bridgeServer.ts
|
||||||
|
lockfile.ts
|
||||||
|
workspaceInfo.ts
|
||||||
|
selectionPublisher.ts
|
||||||
|
diffController.ts
|
||||||
|
protocol.ts
|
||||||
|
util/
|
||||||
|
randomToken.ts
|
||||||
|
disposables.ts
|
||||||
|
test/
|
||||||
|
selectionPublisher.test.ts
|
||||||
|
lockfile.test.ts
|
||||||
|
bridgeServer.test.ts
|
||||||
|
diffController.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
各模块职责如下:
|
||||||
|
|
||||||
|
- `extension.ts`
|
||||||
|
VSCode 扩展入口,负责激活、停用、启动 bridge、注册命令。
|
||||||
|
|
||||||
|
- `bridgeServer.ts`
|
||||||
|
本地 WebSocket 服务与消息路由层,负责握手、鉴权、连接管理,以及把单个 WebSocket 连接桥接为 MCP transport。
|
||||||
|
|
||||||
|
- `lockfile.ts`
|
||||||
|
负责写 lockfile、更新 lockfile、删除 lockfile。
|
||||||
|
|
||||||
|
- `workspaceInfo.ts`
|
||||||
|
负责采集工作区目录、平台信息、活动编辑器文件路径。
|
||||||
|
|
||||||
|
- `selectionPublisher.ts`
|
||||||
|
监听 VSCode 编辑器事件,并把选区信息转换为 `selection_changed`。
|
||||||
|
|
||||||
|
- `diffController.ts`
|
||||||
|
处理 `openDiff` / `close_tab` / `closeAllDiffTabs` 这三个 MCP tools,维护临时资源和 tab 映射。
|
||||||
|
|
||||||
|
- `protocol.ts`
|
||||||
|
统一定义扩展端需要识别和发送的消息结构,避免字符串散落。
|
||||||
|
|
||||||
|
## 7. 命令与可观察性
|
||||||
|
|
||||||
|
虽然主流程是自动连接,但第一版仍建议提供两个调试命令:
|
||||||
|
|
||||||
|
- `Claude Code Bridge: Restart`
|
||||||
|
- `Claude Code Bridge: Show Status`
|
||||||
|
|
||||||
|
状态信息至少包含:
|
||||||
|
|
||||||
|
- 当前监听端口
|
||||||
|
- lockfile 路径
|
||||||
|
- 是否有 CLI 已连接
|
||||||
|
- 当前工作区数量
|
||||||
|
- 最近一次选区推送时间
|
||||||
|
|
||||||
|
另外建议注册一个 output channel:
|
||||||
|
|
||||||
|
- `Claude Code IDE Bridge`
|
||||||
|
|
||||||
|
用于输出:
|
||||||
|
|
||||||
|
- 启动日志
|
||||||
|
- 鉴权失败
|
||||||
|
- lockfile 写入失败
|
||||||
|
- diff 打开失败
|
||||||
|
- 连接断开原因
|
||||||
|
|
||||||
|
## 8. 错误处理策略
|
||||||
|
|
||||||
|
### 8.1 端口占用
|
||||||
|
|
||||||
|
- 自动尝试新的随机端口
|
||||||
|
- 更新 lockfile
|
||||||
|
- 在 output channel 中记录端口变化
|
||||||
|
|
||||||
|
### 8.2 lockfile 写入失败
|
||||||
|
|
||||||
|
- bridge 不进入 ready 状态
|
||||||
|
- 弹出 VSCode 错误通知
|
||||||
|
- output channel 记录完整错误
|
||||||
|
|
||||||
|
### 8.3 WebSocket 鉴权失败
|
||||||
|
|
||||||
|
- 拒绝连接
|
||||||
|
- 记录远端地址和失败原因
|
||||||
|
|
||||||
|
### 8.4 活动编辑器为空
|
||||||
|
|
||||||
|
- 发送空选区状态或仅跳过通知
|
||||||
|
- 不抛异常、不打断 bridge 生命周期
|
||||||
|
|
||||||
|
### 8.5 diff 打开失败
|
||||||
|
|
||||||
|
- 返回明确错误结果给 CLI
|
||||||
|
- 不留下半开的临时资源
|
||||||
|
|
||||||
|
### 8.6 扩展退出
|
||||||
|
|
||||||
|
- 关闭 WebSocket server
|
||||||
|
- 删除 lockfile
|
||||||
|
- 释放临时文档资源
|
||||||
|
- 清空 tab 映射
|
||||||
|
|
||||||
|
## 9. 测试方案
|
||||||
|
|
||||||
|
### 9.1 单元测试
|
||||||
|
|
||||||
|
覆盖以下逻辑:
|
||||||
|
|
||||||
|
- lockfile 内容生成与路径选择
|
||||||
|
- 选区对象到协议消息的转换
|
||||||
|
- tab 映射和关闭逻辑
|
||||||
|
- 鉴权令牌校验
|
||||||
|
|
||||||
|
### 9.2 集成测试
|
||||||
|
|
||||||
|
通过 Node/WebSocket 客户端模拟 CLI:
|
||||||
|
|
||||||
|
- 连接本地 bridge server
|
||||||
|
- 验证鉴权成功与失败
|
||||||
|
- 验证 `selection_changed` 是否按预期发送
|
||||||
|
- 验证 `openDiff` / `close_tab` 是否触发预期行为
|
||||||
|
|
||||||
|
### 9.3 手工验证
|
||||||
|
|
||||||
|
手工验证路径:
|
||||||
|
|
||||||
|
1. 启动 VSCode 扩展
|
||||||
|
2. 启动 `claude-code-best`
|
||||||
|
3. 执行 `/ide`
|
||||||
|
4. 确认 CLI 能识别到 VSCode
|
||||||
|
5. 在 VSCode 中选中一段代码并提问
|
||||||
|
6. 确认 CLI 能注入 `<ide_selection>`
|
||||||
|
7. 触发一次 IDE diff
|
||||||
|
8. 确认 diff 标签可打开、保存、关闭
|
||||||
|
|
||||||
|
## 10. 风险与取舍
|
||||||
|
|
||||||
|
### 10.1 MCP 完整兼容风险
|
||||||
|
|
||||||
|
仓库当前 CLI 连接 `ws-ide` 时使用的是 MCP 客户端通路,因此扩展端若实现过薄,可能在握手或工具注册阶段与 CLI 预期不一致。
|
||||||
|
|
||||||
|
**取舍:**
|
||||||
|
第一版只实现 CLI 当前实际会调用到的最小工具与通知,不尝试泛化为完整 MCP server,但协议层要留出扩展空间。
|
||||||
|
|
||||||
|
### 10.2 VSCode diff 资源回收
|
||||||
|
|
||||||
|
VSCode diff 视图不是纯命名 tab,直接按 `tab_name` 定位关闭可能和实际标签生命周期有偏差。
|
||||||
|
|
||||||
|
**取舍:**
|
||||||
|
扩展内部维护显式映射,以资源 URI 为主、`tab_name` 为辅,不依赖 UI 文本匹配。
|
||||||
|
|
||||||
|
### 10.3 多工作区与路径兼容
|
||||||
|
|
||||||
|
Windows、WSL、单根工作区、多根工作区在路径表示上会不同。
|
||||||
|
|
||||||
|
**取舍:**
|
||||||
|
第一版先以本机本地工作区为主,路径统一走绝对路径;WSL/Windows 转换尽量复用 CLI 现有约定,不在扩展端重新发明路径映射。
|
||||||
|
|
||||||
|
## 11. 分阶段交付
|
||||||
|
|
||||||
|
### 第一阶段
|
||||||
|
|
||||||
|
目标:打通本地 VSCode 与 CLI 的最小闭环。
|
||||||
|
|
||||||
|
范围:
|
||||||
|
|
||||||
|
- 启动 `ws-ide`
|
||||||
|
- 写 lockfile
|
||||||
|
- 发送 `selection_changed`
|
||||||
|
- 实现 `openDiff`
|
||||||
|
- 实现 `close_tab`
|
||||||
|
- 实现 `closeAllDiffTabs`
|
||||||
|
- 提供状态命令和日志输出
|
||||||
|
|
||||||
|
### 第二阶段
|
||||||
|
|
||||||
|
目标:增强稳定性和调试能力。
|
||||||
|
|
||||||
|
范围:
|
||||||
|
|
||||||
|
- 更细的错误提示
|
||||||
|
- 更稳定的 tab 生命周期管理
|
||||||
|
- 更多 IDE 状态信息展示
|
||||||
|
- 更完整的集成测试
|
||||||
|
|
||||||
|
## 12. 结论
|
||||||
|
|
||||||
|
推荐按本设计实现独立的 VSCode IDE Bridge 扩展,并让它完全对齐当前 CLI 已有的 `ws-ide` 连接与 IDE 上下文/差异视图协议。这样能在不大改 CLI 上层逻辑的前提下,把 VSCode 选区、当前文件和 diff 预览能力真正打通。
|
||||||
36
packages/vscode-ide-bridge/.vscode/launch.json
vendored
Normal file
36
packages/vscode-ide-bridge/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Run VSCode IDE Bridge",
|
||||||
|
"type": "extensionHost",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeExecutable": "${execPath}",
|
||||||
|
"args": [
|
||||||
|
"--new-window",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--extensionDevelopmentPath=${workspaceFolder}"
|
||||||
|
],
|
||||||
|
"outFiles": [
|
||||||
|
"${workspaceFolder}/dist/**/*.js"
|
||||||
|
],
|
||||||
|
"preLaunchTask": "Build VSCode IDE Bridge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Run VSCode IDE Bridge (Open Claude Code Root)",
|
||||||
|
"type": "extensionHost",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeExecutable": "${execPath}",
|
||||||
|
"args": [
|
||||||
|
"--new-window",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||||
|
"${workspaceFolder}/../.."
|
||||||
|
],
|
||||||
|
"outFiles": [
|
||||||
|
"${workspaceFolder}/dist/**/*.js"
|
||||||
|
],
|
||||||
|
"preLaunchTask": "Build VSCode IDE Bridge"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
47
packages/vscode-ide-bridge/.vscode/tasks.json
vendored
Normal file
47
packages/vscode-ide-bridge/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Build VSCode IDE Bridge",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "bunx",
|
||||||
|
"args": [
|
||||||
|
"tsc",
|
||||||
|
"-p",
|
||||||
|
"tsconfig.json"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"group": "build",
|
||||||
|
"problemMatcher": "$tsc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Test VSCode IDE Bridge",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "bun",
|
||||||
|
"args": [
|
||||||
|
"test",
|
||||||
|
"test"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Package VSCode IDE Bridge",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "bun",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"package"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"group": "build",
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
6
packages/vscode-ide-bridge/.vscodeignore
Normal file
6
packages/vscode-ide-bridge/.vscodeignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
src/**
|
||||||
|
test/**
|
||||||
|
.vscode/**
|
||||||
|
tsconfig.json
|
||||||
|
*.tsbuildinfo
|
||||||
|
dist/server/**
|
||||||
3
packages/vscode-ide-bridge/LICENSE.txt
Normal file
3
packages/vscode-ide-bridge/LICENSE.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
UNLICENSED
|
||||||
|
|
||||||
|
This package is not licensed for public redistribution.
|
||||||
59
packages/vscode-ide-bridge/README.md
Normal file
59
packages/vscode-ide-bridge/README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# VSCode IDE Bridge
|
||||||
|
|
||||||
|
这是一个给当前仓库配套的本地 VSCode 扩展,用来把 VSCode 和现有 Claude Code CLI 的 `ws-ide` 链路接起来。
|
||||||
|
|
||||||
|
## 当前能力
|
||||||
|
|
||||||
|
- 在本地 `127.0.0.1` 启动 `ws-ide` WebSocket 服务
|
||||||
|
- 写出 CLI 可发现的 `~/.claude/ide/<port>.lock`
|
||||||
|
- 把 VSCode 当前活动文件和选区变化发送为 `selection_changed`
|
||||||
|
- 实现 `openDiff`、`close_tab`、`closeAllDiffTabs` 三个 IDE MCP tools
|
||||||
|
- 提供 `Claude Code Bridge: Restart` 和 `Claude Code Bridge: Show Status` 两个调试命令
|
||||||
|
|
||||||
|
## 当前限制
|
||||||
|
|
||||||
|
- diff 现在支持通过保存右侧文件把修改回传给 CLI,但还没有补“未保存直接接受右侧手工编辑”这类更细的交互
|
||||||
|
- 还没有补 `openFile`、`getDiagnostics`、`at_mentioned`、`log_event` 这些附加能力
|
||||||
|
- 目前按单个活动 CLI 连接设计,新连接会替换旧连接
|
||||||
|
|
||||||
|
## 本地使用
|
||||||
|
|
||||||
|
推荐把这个目录单独当成一个扩展工程来打开,而不是总是从 monorepo 根目录调试。
|
||||||
|
|
||||||
|
1. 在 VSCode 中直接打开 `packages/vscode-ide-bridge`
|
||||||
|
2. 打开“运行和调试”
|
||||||
|
3. 二选一:
|
||||||
|
- `Run VSCode IDE Bridge`
|
||||||
|
- `Run VSCode IDE Bridge (Open Claude Code Root)`,会直接在测试窗口里打开 monorepo 根目录
|
||||||
|
4. 这会自动先执行 `Build VSCode IDE Bridge`
|
||||||
|
5. 如果用了第一个启动项,就在新开的 Extension Development Host 窗口中再打开你真正要联调的目标工作区
|
||||||
|
如果用了第二个启动项,会直接打开 `claude-code` 根目录
|
||||||
|
6. 打开命令面板,执行 `Claude Code Bridge: Show Status`
|
||||||
|
7. 确认输出中已经出现监听端口和 lockfile 路径
|
||||||
|
8. 在这个测试窗口的集成终端里启动 Claude Code CLI;如果没有自动连上,再执行 `/ide`
|
||||||
|
|
||||||
|
这个目录自带自己的 VSCode 配置:
|
||||||
|
|
||||||
|
- `Run VSCode IDE Bridge`
|
||||||
|
- `Run VSCode IDE Bridge (Open Claude Code Root)`
|
||||||
|
- `Build VSCode IDE Bridge`
|
||||||
|
- `Test VSCode IDE Bridge`
|
||||||
|
- `Package VSCode IDE Bridge`
|
||||||
|
|
||||||
|
如果你仍然从 monorepo 根目录开发,也可以继续使用根目录下的 `.vscode` 配置。
|
||||||
|
|
||||||
|
## 打包
|
||||||
|
|
||||||
|
可以直接在这个包目录里执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run package
|
||||||
|
```
|
||||||
|
|
||||||
|
成功后会在 `dist/vscode-ide-bridge.vsix` 生成可安装的 VSCode 扩展包。
|
||||||
|
|
||||||
|
## 验证建议
|
||||||
|
|
||||||
|
- 选中一段代码后发起提问,确认 CLI prompt 中出现 `<ide_selection>`
|
||||||
|
- 触发一次文件 diff,确认 VSCode 中会打开 diff,并能通过通知选择“接受”或“拒绝”
|
||||||
|
- 查看 `Claude Code IDE Bridge` output channel,确认没有鉴权失败或 lockfile 写入失败
|
||||||
59
packages/vscode-ide-bridge/package.json
Normal file
59
packages/vscode-ide-bridge/package.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "vscode-ide-bridge",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Local VSCode ws-ide bridge for Claude Code",
|
||||||
|
"displayName": "Claude Code IDE Bridge",
|
||||||
|
"publisher": "claude-code-best",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/extension.js",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/claude-code-best/claude-code.git",
|
||||||
|
"directory": "packages/vscode-ide-bridge"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/claude-code-best/claude-code/tree/main/packages/vscode-ide-bridge",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/claude-code-best/claude-code/issues"
|
||||||
|
},
|
||||||
|
"categories": [
|
||||||
|
"Other"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"vscode": "^1.90.0"
|
||||||
|
},
|
||||||
|
"activationEvents": [
|
||||||
|
"onStartupFinished",
|
||||||
|
"onCommand:claudeCodeBridge.restart",
|
||||||
|
"onCommand:claudeCodeBridge.showStatus"
|
||||||
|
],
|
||||||
|
"contributes": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "claudeCodeBridge.restart",
|
||||||
|
"title": "Claude Code Bridge: Restart"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "claudeCodeBridge.showStatus",
|
||||||
|
"title": "Claude Code Bridge: Show Status"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "bunx tsc -p tsconfig.json",
|
||||||
|
"bundle": "bun build ./src/extension.ts --outdir dist --target node --format esm --external vscode",
|
||||||
|
"test": "bun test",
|
||||||
|
"check": "bunx tsc -p tsconfig.json --pretty false",
|
||||||
|
"package": "bun run bundle && bunx @vscode/vsce package --no-dependencies --out dist/vscode-ide-bridge.vsix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"ws": "^8.20.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vscode/vsce": "^3.7.0",
|
||||||
|
"@types/bun": "^1.3.11",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
61
packages/vscode-ide-bridge/src/extension.ts
Normal file
61
packages/vscode-ide-bridge/src/extension.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import * as vscode from 'vscode'
|
||||||
|
import { LocalIdeBridgeService } from './server/localIdeBridgeService.js'
|
||||||
|
|
||||||
|
let bridgeService: LocalIdeBridgeService | null = null
|
||||||
|
|
||||||
|
export async function activate(context: any): Promise<void> {
|
||||||
|
const outputChannel = vscode.window.createOutputChannel(
|
||||||
|
'Claude Code IDE Bridge',
|
||||||
|
)
|
||||||
|
|
||||||
|
bridgeService = new LocalIdeBridgeService(
|
||||||
|
vscode,
|
||||||
|
outputChannel,
|
||||||
|
context.environmentVariableCollection,
|
||||||
|
)
|
||||||
|
await bridgeService.start()
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
|
outputChannel,
|
||||||
|
{
|
||||||
|
dispose: () => {
|
||||||
|
void bridgeService?.dispose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vscode.commands.registerCommand('claudeCodeBridge.restart', async () => {
|
||||||
|
await bridgeService?.restart()
|
||||||
|
const status = bridgeService?.getStatus()
|
||||||
|
vscode.window.showInformationMessage(
|
||||||
|
`Claude Code Bridge 已重启${status?.port ? `,端口 ${status.port}` : ''}`,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
vscode.commands.registerCommand('claudeCodeBridge.showStatus', () => {
|
||||||
|
const status = bridgeService?.getStatus()
|
||||||
|
outputChannel.show(true)
|
||||||
|
outputChannel.appendLine(
|
||||||
|
`[status] port=${status?.port ?? 'n/a'} connected=${String(status?.hasConnectedClient ?? false)} cliPid=${status?.connectedCliPid ?? 'n/a'} lockfile=${status?.lockfilePath ?? 'n/a'}`,
|
||||||
|
)
|
||||||
|
vscode.window.showInformationMessage(
|
||||||
|
status?.port
|
||||||
|
? `Claude Code Bridge 正在监听 127.0.0.1:${status.port}`
|
||||||
|
: 'Claude Code Bridge 尚未启动',
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
vscode.window.onDidChangeTextEditorSelection(() => {
|
||||||
|
void bridgeService?.publishActiveSelection()
|
||||||
|
}),
|
||||||
|
vscode.window.onDidChangeActiveTextEditor(() => {
|
||||||
|
void bridgeService?.publishActiveSelection()
|
||||||
|
}),
|
||||||
|
vscode.workspace.onDidChangeWorkspaceFolders(() => {
|
||||||
|
void bridgeService?.refreshLockfile()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await bridgeService.publishActiveSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivate(): Promise<void> {
|
||||||
|
await bridgeService?.dispose()
|
||||||
|
bridgeService = null
|
||||||
|
}
|
||||||
139
packages/vscode-ide-bridge/src/server/bridgeServer.ts
Normal file
139
packages/vscode-ide-bridge/src/server/bridgeServer.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
type CallToolResult,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
type Tool,
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
import type { SelectionChangedParams } from './selectionPublisher.js'
|
||||||
|
import {
|
||||||
|
CloseAllDiffTabsArgumentsSchema,
|
||||||
|
CloseTabArgumentsSchema,
|
||||||
|
IdeConnectedNotificationSchema,
|
||||||
|
OpenDiffArgumentsSchema,
|
||||||
|
type CloseTabArguments,
|
||||||
|
type OpenDiffArguments,
|
||||||
|
} from './protocol.js'
|
||||||
|
|
||||||
|
export type DiffController = {
|
||||||
|
openDiff(args: OpenDiffArguments): Promise<CallToolResult>
|
||||||
|
closeTab(args: CloseTabArguments): Promise<CallToolResult>
|
||||||
|
closeAllDiffTabs(): Promise<CallToolResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateIdeBridgeServerOptions = {
|
||||||
|
diffController: DiffController
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDE_BRIDGE_TOOLS: Tool[] = [
|
||||||
|
{
|
||||||
|
name: 'openDiff',
|
||||||
|
description: 'Open a diff view in the IDE and resolve when the user acts.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
old_file_path: { type: 'string' },
|
||||||
|
new_file_path: { type: 'string' },
|
||||||
|
new_file_contents: { type: 'string' },
|
||||||
|
tab_name: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: [
|
||||||
|
'old_file_path',
|
||||||
|
'new_file_path',
|
||||||
|
'new_file_contents',
|
||||||
|
'tab_name',
|
||||||
|
],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'close_tab',
|
||||||
|
description: 'Close a previously opened IDE tab by Claude Code tab name.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tab_name: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['tab_name'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'closeAllDiffTabs',
|
||||||
|
description: 'Close all diff tabs created by the IDE bridge.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function createIdeBridgeServer(options: CreateIdeBridgeServerOptions): {
|
||||||
|
server: Server
|
||||||
|
notifySelectionChanged(params: SelectionChangedParams): Promise<void>
|
||||||
|
getConnectedCliPid(): number | null
|
||||||
|
} {
|
||||||
|
const server = new Server(
|
||||||
|
{
|
||||||
|
name: 'claude-code-vscode-ide-bridge',
|
||||||
|
version: '0.0.1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
let connectedCliPid: number | null = null
|
||||||
|
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
tools: IDE_BRIDGE_TOOLS,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||||
|
switch (request.params.name) {
|
||||||
|
case 'openDiff':
|
||||||
|
return options.diffController.openDiff(
|
||||||
|
OpenDiffArgumentsSchema.parse(request.params.arguments ?? {}),
|
||||||
|
)
|
||||||
|
case 'close_tab':
|
||||||
|
return options.diffController.closeTab(
|
||||||
|
CloseTabArgumentsSchema.parse(request.params.arguments ?? {}),
|
||||||
|
)
|
||||||
|
case 'closeAllDiffTabs':
|
||||||
|
CloseAllDiffTabsArgumentsSchema.parse(request.params.arguments ?? {})
|
||||||
|
return options.diffController.closeAllDiffTabs()
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Unsupported IDE tool: ${request.params.name}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.setNotificationHandler(IdeConnectedNotificationSchema, notification => {
|
||||||
|
connectedCliPid = notification.params.pid
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
async notifySelectionChanged(params) {
|
||||||
|
await server.notification({
|
||||||
|
method: 'selection_changed',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getConnectedCliPid() {
|
||||||
|
return connectedCliPid
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
350
packages/vscode-ide-bridge/src/server/diffController.ts
Normal file
350
packages/vscode-ide-bridge/src/server/diffController.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
import * as vscode from 'vscode'
|
||||||
|
import type { DiffController } from './bridgeServer.js'
|
||||||
|
import type { OpenDiffArguments } from './protocol.js'
|
||||||
|
|
||||||
|
const DIFF_SCHEME = 'claude-code-bridge'
|
||||||
|
const ACCEPT_LABEL = '接受'
|
||||||
|
const REJECT_LABEL = '拒绝'
|
||||||
|
|
||||||
|
type DiffSession = {
|
||||||
|
tabName: string
|
||||||
|
leftUri: any
|
||||||
|
rightUri: any
|
||||||
|
filePath: string
|
||||||
|
hasBeenVisible: boolean
|
||||||
|
settled: boolean
|
||||||
|
resolve: (result: CallToolResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
class VirtualDocumentProvider {
|
||||||
|
private readonly contents = new Map<string, string>()
|
||||||
|
|
||||||
|
provideTextDocumentContent(uri: any): string {
|
||||||
|
return this.contents.get(uri.toString()) ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
set(uri: any, content: string): void {
|
||||||
|
this.contents.set(uri.toString(), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(uri: any): void {
|
||||||
|
this.contents.delete(uri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTextResult(text: string): CallToolResult {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFileSavedResult(contents: string): CallToolResult {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'FILE_SAVED',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: contents,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDiffUri(kind: 'left' | 'right', tabName: string, filePath: string) {
|
||||||
|
return vscode.Uri.parse(
|
||||||
|
`${DIFF_SCHEME}:/${kind}/${encodeURIComponent(tabName)}?filePath=${encodeURIComponent(filePath)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDocumentFullRange(document: any): any {
|
||||||
|
const lineCount = Math.max(document?.lineCount ?? 1, 1)
|
||||||
|
const lastLine = document?.lineAt?.(lineCount - 1)
|
||||||
|
const lastCharacter = lastLine?.text?.length ?? 0
|
||||||
|
return new vscode.Range(0, 0, lineCount - 1, lastCharacter)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceDocumentContents(
|
||||||
|
editor: any,
|
||||||
|
nextContent: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const currentContent = editor?.document?.getText?.() ?? ''
|
||||||
|
if (currentContent === nextContent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await editor.edit((editBuilder: any) => {
|
||||||
|
editBuilder.replace(
|
||||||
|
getDocumentFullRange(editor.document),
|
||||||
|
nextContent,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSessionDocument(session: DiffSession, document: any): boolean {
|
||||||
|
const uriString = document?.uri?.toString?.()
|
||||||
|
const fsPath = document?.uri?.fsPath
|
||||||
|
|
||||||
|
return (
|
||||||
|
uriString === session.rightUri.toString() ||
|
||||||
|
(typeof fsPath === 'string' && fsPath === session.filePath)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDiffController(outputChannel: any): DiffController & {
|
||||||
|
dispose(): Promise<void>
|
||||||
|
} {
|
||||||
|
const provider = new VirtualDocumentProvider()
|
||||||
|
const sessions = new Map<string, DiffSession>()
|
||||||
|
|
||||||
|
const providerDisposable =
|
||||||
|
vscode.workspace.registerTextDocumentContentProvider(
|
||||||
|
DIFF_SCHEME,
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibilityDisposable = vscode.window.onDidChangeVisibleTextEditors(
|
||||||
|
(editors: any[]) => {
|
||||||
|
const visibleUris = new Set(
|
||||||
|
editors.map(editor => editor?.document?.uri?.toString?.()),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
const leftVisible = visibleUris.has(session.leftUri.toString())
|
||||||
|
const rightVisible = visibleUris.has(session.rightUri.toString())
|
||||||
|
|
||||||
|
if (leftVisible || rightVisible) {
|
||||||
|
session.hasBeenVisible = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.hasBeenVisible) {
|
||||||
|
void settleSession(
|
||||||
|
session.tabName,
|
||||||
|
createTextResult('TAB_CLOSED'),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveDisposable = vscode.workspace.onDidSaveTextDocument(
|
||||||
|
(document: any) => {
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
if (!matchesSessionDocument(session, document)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
void settleSession(
|
||||||
|
session.tabName,
|
||||||
|
createFileSavedResult(document.getText()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function settleSession(
|
||||||
|
tabName: string,
|
||||||
|
result: CallToolResult,
|
||||||
|
closeEditors: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = sessions.get(tabName)
|
||||||
|
if (!session || session.settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.settled = true
|
||||||
|
sessions.delete(tabName)
|
||||||
|
provider.delete(session.leftUri)
|
||||||
|
provider.delete(session.rightUri)
|
||||||
|
|
||||||
|
if (closeEditors) {
|
||||||
|
await closeSessionEditors(session).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
session.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSessionEditors(session: DiffSession): Promise<void> {
|
||||||
|
for (const editor of vscode.window.visibleTextEditors ?? []) {
|
||||||
|
if (
|
||||||
|
matchesSessionDocument(session, editor?.document) &&
|
||||||
|
editor?.document?.isDirty
|
||||||
|
) {
|
||||||
|
await vscode.window.showTextDocument(editor.document, {
|
||||||
|
preview: false,
|
||||||
|
preserveFocus: false,
|
||||||
|
viewColumn: editor.viewColumn,
|
||||||
|
})
|
||||||
|
await vscode.commands.executeCommand('workbench.action.files.revert')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedTabs: any[] = []
|
||||||
|
|
||||||
|
for (const group of vscode.window.tabGroups?.all ?? []) {
|
||||||
|
for (const tab of group.tabs ?? []) {
|
||||||
|
const original = tab?.input?.original?.toString?.()
|
||||||
|
const modified = tab?.input?.modified?.toString?.()
|
||||||
|
const uri = tab?.input?.uri?.toString?.()
|
||||||
|
if (
|
||||||
|
original === session.leftUri.toString() ||
|
||||||
|
modified === session.rightUri.toString() ||
|
||||||
|
uri === session.rightUri.toString() ||
|
||||||
|
tab?.input?.uri?.fsPath === session.filePath ||
|
||||||
|
tab?.label === session.tabName
|
||||||
|
) {
|
||||||
|
matchedTabs.push(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedTabs.length > 0 && vscode.window.tabGroups?.close) {
|
||||||
|
await vscode.window.tabGroups.close(matchedTabs, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const editor of vscode.window.visibleTextEditors ?? []) {
|
||||||
|
const uri = editor?.document?.uri?.toString?.()
|
||||||
|
if (
|
||||||
|
uri === session.leftUri.toString() ||
|
||||||
|
uri === session.rightUri.toString()
|
||||||
|
) {
|
||||||
|
await vscode.window.showTextDocument(editor.document, {
|
||||||
|
preview: false,
|
||||||
|
preserveFocus: false,
|
||||||
|
viewColumn: editor.viewColumn,
|
||||||
|
})
|
||||||
|
await vscode.commands.executeCommand('workbench.action.closeActiveEditor')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async openDiff(args: OpenDiffArguments): Promise<CallToolResult> {
|
||||||
|
await settleSession(args.tab_name, createTextResult('TAB_CLOSED'), true)
|
||||||
|
|
||||||
|
const leftContent = await readFile(args.old_file_path, 'utf8').catch(
|
||||||
|
() => '',
|
||||||
|
)
|
||||||
|
const leftUri = buildDiffUri('left', args.tab_name, args.old_file_path)
|
||||||
|
const rightUri = vscode.Uri.file(args.new_file_path)
|
||||||
|
|
||||||
|
provider.set(leftUri, leftContent)
|
||||||
|
|
||||||
|
const rightDocument = await vscode.workspace.openTextDocument(rightUri)
|
||||||
|
const rightEditor = await vscode.window.showTextDocument(rightDocument, {
|
||||||
|
preview: false,
|
||||||
|
preserveFocus: true,
|
||||||
|
})
|
||||||
|
await replaceDocumentContents(rightEditor, args.new_file_contents)
|
||||||
|
|
||||||
|
const resultPromise = new Promise<CallToolResult>(resolve => {
|
||||||
|
sessions.set(args.tab_name, {
|
||||||
|
tabName: args.tab_name,
|
||||||
|
leftUri,
|
||||||
|
rightUri,
|
||||||
|
filePath: args.new_file_path,
|
||||||
|
hasBeenVisible: false,
|
||||||
|
settled: false,
|
||||||
|
resolve,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
outputChannel.appendLine(
|
||||||
|
`[diff] open ${args.tab_name} -> ${args.new_file_path}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await vscode.commands.executeCommand(
|
||||||
|
'vscode.diff',
|
||||||
|
leftUri,
|
||||||
|
rightUri,
|
||||||
|
args.tab_name,
|
||||||
|
{
|
||||||
|
preview: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const visibleUris = new Set(
|
||||||
|
(vscode.window.visibleTextEditors ?? []).map((editor: any) =>
|
||||||
|
editor?.document?.uri?.toString?.(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const session = sessions.get(args.tab_name)
|
||||||
|
if (!session) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
visibleUris.has(session.leftUri.toString()) ||
|
||||||
|
visibleUris.has(session.rightUri.toString())
|
||||||
|
) {
|
||||||
|
session.hasBeenVisible = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
void vscode.window
|
||||||
|
.showInformationMessage(
|
||||||
|
`Claude Code 提议了对 ${args.new_file_path} 的修改`,
|
||||||
|
ACCEPT_LABEL,
|
||||||
|
REJECT_LABEL,
|
||||||
|
)
|
||||||
|
.then((choice: string | undefined) => {
|
||||||
|
if (choice === ACCEPT_LABEL) {
|
||||||
|
void settleSession(
|
||||||
|
args.tab_name,
|
||||||
|
createTextResult('TAB_CLOSED'),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
} else if (choice === REJECT_LABEL) {
|
||||||
|
void settleSession(
|
||||||
|
args.tab_name,
|
||||||
|
createTextResult('DIFF_REJECTED'),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return resultPromise
|
||||||
|
},
|
||||||
|
|
||||||
|
async closeTab(args): Promise<CallToolResult> {
|
||||||
|
const session = sessions.get(args.tab_name)
|
||||||
|
if (session) {
|
||||||
|
await closeSessionEditors(session).catch(() => {})
|
||||||
|
await settleSession(args.tab_name, createTextResult('TAB_CLOSED'), false)
|
||||||
|
}
|
||||||
|
return createTextResult('TAB_CLOSED')
|
||||||
|
},
|
||||||
|
|
||||||
|
async closeAllDiffTabs(): Promise<CallToolResult> {
|
||||||
|
for (const tabName of [...sessions.keys()]) {
|
||||||
|
const session = sessions.get(tabName)
|
||||||
|
if (!session) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await closeSessionEditors(session).catch(() => {})
|
||||||
|
await settleSession(tabName, createTextResult('TAB_CLOSED'), false)
|
||||||
|
}
|
||||||
|
return createTextResult('OK')
|
||||||
|
},
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
visibilityDisposable.dispose()
|
||||||
|
saveDisposable.dispose()
|
||||||
|
providerDisposable.dispose()
|
||||||
|
await this.closeAllDiffTabs()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
231
packages/vscode-ide-bridge/src/server/localIdeBridgeService.ts
Normal file
231
packages/vscode-ide-bridge/src/server/localIdeBridgeService.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { WebSocketServer } from 'ws'
|
||||||
|
import { createIdeBridgeServer } from './bridgeServer.js'
|
||||||
|
import { createDiffController } from './diffController.js'
|
||||||
|
import {
|
||||||
|
buildLockfilePayload,
|
||||||
|
removeLockfile,
|
||||||
|
writeLockfile,
|
||||||
|
} from './lockfile.js'
|
||||||
|
import { createAuthToken } from './randomToken.js'
|
||||||
|
import { ServerWebSocketTransport } from './serverWebSocketTransport.js'
|
||||||
|
import {
|
||||||
|
clearClaudeCodeIdePort,
|
||||||
|
setClaudeCodeIdePort,
|
||||||
|
} from './terminalEnvironment.js'
|
||||||
|
import { getActiveSelectionSnapshot, getWorkspaceFolderPaths } from './workspaceInfo.js'
|
||||||
|
|
||||||
|
type BridgeStatus = {
|
||||||
|
port: number | null
|
||||||
|
lockfilePath: string | null
|
||||||
|
hasConnectedClient: boolean
|
||||||
|
connectedCliPid: number | null
|
||||||
|
workspaceFolders: string[]
|
||||||
|
lastSelectionSentAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveConnection = {
|
||||||
|
socket: any
|
||||||
|
bridge: ReturnType<typeof createIdeBridgeServer>
|
||||||
|
transport: ServerWebSocketTransport
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LocalIdeBridgeService {
|
||||||
|
private readonly diffController
|
||||||
|
private readonly ideName = 'VS Code'
|
||||||
|
private readonly runningInWindows = process.platform === 'win32'
|
||||||
|
|
||||||
|
private server: any | null = null
|
||||||
|
private port: number | null = null
|
||||||
|
private lockfilePath: string | null = null
|
||||||
|
private authToken = ''
|
||||||
|
private activeConnection: ActiveConnection | null = null
|
||||||
|
private lastSelectionSentAt: string | null = null
|
||||||
|
private disposed = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly vscode: any,
|
||||||
|
private readonly outputChannel: any,
|
||||||
|
private readonly environmentVariableCollection?: {
|
||||||
|
replace(name: string, value: string): void
|
||||||
|
delete(name: string): void
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.diffController = createDiffController(outputChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.server || this.disposed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.authToken = createAuthToken()
|
||||||
|
this.server = await this.createWebSocketServer()
|
||||||
|
this.port = this.getServerPort()
|
||||||
|
await this.refreshLockfile()
|
||||||
|
|
||||||
|
this.outputChannel.appendLine(
|
||||||
|
`[bridge] listening on ws://127.0.0.1:${this.port}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async restart(): Promise<void> {
|
||||||
|
await this.stop()
|
||||||
|
this.disposed = false
|
||||||
|
await this.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshLockfile(): Promise<void> {
|
||||||
|
if (!this.port) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setClaudeCodeIdePort(this.environmentVariableCollection, this.port)
|
||||||
|
await removeLockfile(this.lockfilePath)
|
||||||
|
this.lockfilePath = await writeLockfile(
|
||||||
|
this.port,
|
||||||
|
buildLockfilePayload({
|
||||||
|
pid: process.pid,
|
||||||
|
ideName: this.ideName,
|
||||||
|
workspaceFolders: getWorkspaceFolderPaths(
|
||||||
|
this.vscode.workspace.workspaceFolders,
|
||||||
|
),
|
||||||
|
authToken: this.authToken,
|
||||||
|
runningInWindows: this.runningInWindows,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.outputChannel.appendLine(`[bridge] lockfile -> ${this.lockfilePath}`)
|
||||||
|
this.outputChannel.appendLine(
|
||||||
|
`[bridge] terminal env CLAUDE_CODE_SSE_PORT=${this.port}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishActiveSelection(): Promise<void> {
|
||||||
|
if (!this.activeConnection) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = getActiveSelectionSnapshot(this.vscode.window.activeTextEditor)
|
||||||
|
|
||||||
|
if (!snapshot.selection && !snapshot.filePath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.activeConnection.bridge.notifySelectionChanged(snapshot)
|
||||||
|
this.lastSelectionSentAt = new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): BridgeStatus {
|
||||||
|
return {
|
||||||
|
port: this.port,
|
||||||
|
lockfilePath: this.lockfilePath,
|
||||||
|
hasConnectedClient: this.activeConnection !== null,
|
||||||
|
connectedCliPid:
|
||||||
|
this.activeConnection?.bridge.getConnectedCliPid() ?? null,
|
||||||
|
workspaceFolders: getWorkspaceFolderPaths(
|
||||||
|
this.vscode.workspace.workspaceFolders,
|
||||||
|
),
|
||||||
|
lastSelectionSentAt: this.lastSelectionSentAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
await this.closeActiveConnection()
|
||||||
|
|
||||||
|
if (this.server) {
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
this.server?.close(() => resolve())
|
||||||
|
})
|
||||||
|
this.server = null
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeLockfile(this.lockfilePath)
|
||||||
|
clearClaudeCodeIdePort(this.environmentVariableCollection)
|
||||||
|
this.lockfilePath = null
|
||||||
|
this.port = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
if (this.disposed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disposed = true
|
||||||
|
await this.stop()
|
||||||
|
await this.diffController.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createWebSocketServer(): Promise<any> {
|
||||||
|
const server = new WebSocketServer({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('listening', () => resolve())
|
||||||
|
server.once('error', (error: Error) => reject(error))
|
||||||
|
})
|
||||||
|
|
||||||
|
server.on('connection', (socket: any, request: any) => {
|
||||||
|
const authHeader = request.headers['x-claude-code-ide-authorization']
|
||||||
|
if (authHeader !== this.authToken) {
|
||||||
|
this.outputChannel.appendLine('[bridge] rejected unauthorized client')
|
||||||
|
socket.close(4003, 'unauthorized')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.handleConnection(socket)
|
||||||
|
})
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
private getServerPort(): number {
|
||||||
|
const address = this.server?.address()
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
throw new Error('Unable to determine bridge port')
|
||||||
|
}
|
||||||
|
return address.port
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleConnection(socket: any): Promise<void> {
|
||||||
|
await this.closeActiveConnection()
|
||||||
|
|
||||||
|
const bridge = createIdeBridgeServer({
|
||||||
|
diffController: this.diffController,
|
||||||
|
})
|
||||||
|
const transport = new ServerWebSocketTransport(socket)
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (this.activeConnection?.socket === socket) {
|
||||||
|
this.activeConnection = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await bridge.server.connect(transport)
|
||||||
|
|
||||||
|
this.activeConnection = {
|
||||||
|
socket,
|
||||||
|
bridge,
|
||||||
|
transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.outputChannel.appendLine('[bridge] CLI client connected')
|
||||||
|
await this.publishActiveSelection().catch(error => {
|
||||||
|
this.outputChannel.appendLine(
|
||||||
|
`[bridge] failed to publish initial selection: ${(error as Error).message}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closeActiveConnection(): Promise<void> {
|
||||||
|
if (!this.activeConnection) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = this.activeConnection
|
||||||
|
this.activeConnection = null
|
||||||
|
|
||||||
|
await connection.transport.close().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
56
packages/vscode-ide-bridge/src/server/lockfile.ts
Normal file
56
packages/vscode-ide-bridge/src/server/lockfile.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { LockfilePayload } from './protocol.js'
|
||||||
|
|
||||||
|
type BuildLockfilePayloadInput = {
|
||||||
|
pid: number
|
||||||
|
ideName: string
|
||||||
|
workspaceFolders: string[]
|
||||||
|
authToken: string
|
||||||
|
runningInWindows: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClaudeConfigDir(): string {
|
||||||
|
return (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')).normalize(
|
||||||
|
'NFC',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLockfilePayload(
|
||||||
|
input: BuildLockfilePayloadInput,
|
||||||
|
): LockfilePayload {
|
||||||
|
return {
|
||||||
|
workspaceFolders: input.workspaceFolders,
|
||||||
|
pid: input.pid,
|
||||||
|
ideName: input.ideName,
|
||||||
|
transport: 'ws',
|
||||||
|
runningInWindows: input.runningInWindows,
|
||||||
|
authToken: input.authToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLockfileDir(): string {
|
||||||
|
return join(getClaudeConfigDir(), 'ide')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLockfilePath(port: number): string {
|
||||||
|
return join(getLockfileDir(), `${port}.lock`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeLockfile(
|
||||||
|
port: number,
|
||||||
|
payload: LockfilePayload,
|
||||||
|
): Promise<string> {
|
||||||
|
const lockfilePath = getLockfilePath(port)
|
||||||
|
await mkdir(getLockfileDir(), { recursive: true })
|
||||||
|
await writeFile(lockfilePath, JSON.stringify(payload), 'utf8')
|
||||||
|
return lockfilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeLockfile(lockfilePath: string | null): Promise<void> {
|
||||||
|
if (!lockfilePath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await rm(lockfilePath, { force: true })
|
||||||
|
}
|
||||||
33
packages/vscode-ide-bridge/src/server/protocol.ts
Normal file
33
packages/vscode-ide-bridge/src/server/protocol.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from 'zod/v4'
|
||||||
|
|
||||||
|
export type LockfilePayload = {
|
||||||
|
workspaceFolders: string[]
|
||||||
|
pid: number
|
||||||
|
ideName: string
|
||||||
|
transport: 'ws'
|
||||||
|
runningInWindows: boolean
|
||||||
|
authToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpenDiffArgumentsSchema = z.object({
|
||||||
|
old_file_path: z.string(),
|
||||||
|
new_file_path: z.string(),
|
||||||
|
new_file_contents: z.string(),
|
||||||
|
tab_name: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CloseTabArgumentsSchema = z.object({
|
||||||
|
tab_name: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CloseAllDiffTabsArgumentsSchema = z.object({})
|
||||||
|
|
||||||
|
export const IdeConnectedNotificationSchema = z.object({
|
||||||
|
method: z.literal('ide_connected'),
|
||||||
|
params: z.object({
|
||||||
|
pid: z.number(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type OpenDiffArguments = z.infer<typeof OpenDiffArgumentsSchema>
|
||||||
|
export type CloseTabArguments = z.infer<typeof CloseTabArgumentsSchema>
|
||||||
5
packages/vscode-ide-bridge/src/server/randomToken.ts
Normal file
5
packages/vscode-ide-bridge/src/server/randomToken.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
|
export function createAuthToken(): string {
|
||||||
|
return randomBytes(24).toString('hex')
|
||||||
|
}
|
||||||
41
packages/vscode-ide-bridge/src/server/selectionPublisher.ts
Normal file
41
packages/vscode-ide-bridge/src/server/selectionPublisher.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export type SelectionPoint = {
|
||||||
|
line: number
|
||||||
|
character: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectionChangedParams = {
|
||||||
|
selection: {
|
||||||
|
start: SelectionPoint
|
||||||
|
end: SelectionPoint
|
||||||
|
} | null
|
||||||
|
text?: string
|
||||||
|
filePath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildSelectionChangedParamsInput = {
|
||||||
|
filePath?: string
|
||||||
|
text?: string
|
||||||
|
start?: SelectionPoint
|
||||||
|
end?: SelectionPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSelectionChangedParams(
|
||||||
|
input: BuildSelectionChangedParamsInput,
|
||||||
|
): SelectionChangedParams {
|
||||||
|
if (!input.start || !input.end) {
|
||||||
|
return {
|
||||||
|
selection: null,
|
||||||
|
text: input.text,
|
||||||
|
filePath: input.filePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selection: {
|
||||||
|
start: input.start,
|
||||||
|
end: input.end,
|
||||||
|
},
|
||||||
|
text: input.text,
|
||||||
|
filePath: input.filePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||||
|
import {
|
||||||
|
type JSONRPCMessage,
|
||||||
|
JSONRPCMessageSchema,
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
|
||||||
|
type WebSocketLike = {
|
||||||
|
readyState: number
|
||||||
|
send(data: string, callback?: (error?: Error) => void): void
|
||||||
|
close(): void
|
||||||
|
on(event: 'message', listener: (data: Buffer | string) => void): void
|
||||||
|
on(event: 'close', listener: () => void): void
|
||||||
|
on(event: 'error', listener: (error: Error) => void): void
|
||||||
|
off(event: 'message', listener: (data: Buffer | string) => void): void
|
||||||
|
off(event: 'close', listener: () => void): void
|
||||||
|
off(event: 'error', listener: (error: Error) => void): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_OPEN = 1
|
||||||
|
|
||||||
|
export class ServerWebSocketTransport implements Transport {
|
||||||
|
private started = false
|
||||||
|
|
||||||
|
constructor(private readonly socket: WebSocketLike) {
|
||||||
|
this.socket.on('message', this.handleMessage)
|
||||||
|
this.socket.on('close', this.handleClose)
|
||||||
|
this.socket.on('error', this.handleError)
|
||||||
|
}
|
||||||
|
|
||||||
|
onclose?: () => void
|
||||||
|
onerror?: (error: Error) => void
|
||||||
|
onmessage?: (message: JSONRPCMessage) => void
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.started) {
|
||||||
|
throw new Error('Start can only be called once per transport.')
|
||||||
|
}
|
||||||
|
if (this.socket.readyState !== WS_OPEN) {
|
||||||
|
throw new Error('WebSocket is not open. Cannot start transport.')
|
||||||
|
}
|
||||||
|
this.started = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(message: JSONRPCMessage): Promise<void> {
|
||||||
|
if (this.socket.readyState !== WS_OPEN) {
|
||||||
|
throw new Error('WebSocket is not open. Cannot send message.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.socket.send(JSON.stringify(message), error => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.socket.readyState === WS_OPEN) {
|
||||||
|
this.socket.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage = (data: Buffer | string) => {
|
||||||
|
try {
|
||||||
|
const raw = typeof data === 'string' ? data : data.toString('utf8')
|
||||||
|
const parsed = JSONRPCMessageSchema.parse(JSON.parse(raw))
|
||||||
|
this.onmessage?.(parsed)
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error instanceof Error ? error : new Error(String(error)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleClose = () => {
|
||||||
|
this.cleanup()
|
||||||
|
this.onclose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError = (error: Error) => {
|
||||||
|
this.onerror?.(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup() {
|
||||||
|
this.socket.off('message', this.handleMessage)
|
||||||
|
this.socket.off('close', this.handleClose)
|
||||||
|
this.socket.off('error', this.handleError)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/vscode-ide-bridge/src/server/terminalEnvironment.ts
Normal file
19
packages/vscode-ide-bridge/src/server/terminalEnvironment.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
type EnvironmentVariableCollectionLike = {
|
||||||
|
replace(name: string, value: string): void
|
||||||
|
delete(name: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLAUDE_CODE_SSE_PORT = 'CLAUDE_CODE_SSE_PORT'
|
||||||
|
|
||||||
|
export function setClaudeCodeIdePort(
|
||||||
|
collection: EnvironmentVariableCollectionLike | undefined,
|
||||||
|
port: number,
|
||||||
|
): void {
|
||||||
|
collection?.replace(CLAUDE_CODE_SSE_PORT, String(port))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearClaudeCodeIdePort(
|
||||||
|
collection: EnvironmentVariableCollectionLike | undefined,
|
||||||
|
): void {
|
||||||
|
collection?.delete(CLAUDE_CODE_SSE_PORT)
|
||||||
|
}
|
||||||
53
packages/vscode-ide-bridge/src/server/workspaceInfo.ts
Normal file
53
packages/vscode-ide-bridge/src/server/workspaceInfo.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { buildSelectionChangedParams } from './selectionPublisher.js'
|
||||||
|
|
||||||
|
type WorkspaceFolderLike = {
|
||||||
|
uri?: {
|
||||||
|
fsPath?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorLike = {
|
||||||
|
document?: {
|
||||||
|
uri?: {
|
||||||
|
fsPath?: string
|
||||||
|
}
|
||||||
|
getText(selection: unknown): string
|
||||||
|
}
|
||||||
|
selection?: {
|
||||||
|
start: {
|
||||||
|
line: number
|
||||||
|
character: number
|
||||||
|
}
|
||||||
|
end: {
|
||||||
|
line: number
|
||||||
|
character: number
|
||||||
|
}
|
||||||
|
isEmpty?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceFolderPaths(
|
||||||
|
workspaceFolders: WorkspaceFolderLike[] | undefined,
|
||||||
|
): string[] {
|
||||||
|
return (workspaceFolders ?? [])
|
||||||
|
.map(folder => folder.uri?.fsPath)
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveSelectionSnapshot(editor: EditorLike | undefined) {
|
||||||
|
const filePath = editor?.document?.uri?.fsPath
|
||||||
|
const selection = editor?.selection
|
||||||
|
|
||||||
|
if (!editor?.document || !selection || selection.isEmpty) {
|
||||||
|
return buildSelectionChangedParams({
|
||||||
|
filePath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildSelectionChangedParams({
|
||||||
|
filePath,
|
||||||
|
text: editor.document.getText(selection),
|
||||||
|
start: selection.start,
|
||||||
|
end: selection.end,
|
||||||
|
})
|
||||||
|
}
|
||||||
4
packages/vscode-ide-bridge/src/vscode.d.ts
vendored
Normal file
4
packages/vscode-ide-bridge/src/vscode.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module 'vscode' {
|
||||||
|
const vscode: any
|
||||||
|
export = vscode
|
||||||
|
}
|
||||||
3
packages/vscode-ide-bridge/src/ws.d.ts
vendored
Normal file
3
packages/vscode-ide-bridge/src/ws.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
declare module 'ws' {
|
||||||
|
export const WebSocketServer: any
|
||||||
|
}
|
||||||
135
packages/vscode-ide-bridge/test/bridgeServer.test.ts
Normal file
135
packages/vscode-ide-bridge/test/bridgeServer.test.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { z } from 'zod/v4'
|
||||||
|
import { createLinkedTransportPair } from '../../../src/services/mcp/InProcessTransport.js'
|
||||||
|
import {
|
||||||
|
createIdeBridgeServer,
|
||||||
|
type DiffController,
|
||||||
|
} from '../src/server/bridgeServer.js'
|
||||||
|
|
||||||
|
const SelectionChangedSchema = z.object({
|
||||||
|
method: z.literal('selection_changed'),
|
||||||
|
params: z.object({
|
||||||
|
selection: z
|
||||||
|
.object({
|
||||||
|
start: z.object({ line: z.number(), character: z.number() }),
|
||||||
|
end: z.object({ line: z.number(), character: z.number() }),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
filePath: z.string().optional(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function createTestClient() {
|
||||||
|
return new Client({
|
||||||
|
name: 'vscode-ide-bridge-test-client',
|
||||||
|
version: '0.0.1',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ide bridge MCP server', () => {
|
||||||
|
test('lists the bridge tools and delegates openDiff calls', async () => {
|
||||||
|
const openDiffCalls: Array<Record<string, unknown>> = []
|
||||||
|
const diffController: DiffController = {
|
||||||
|
async openDiff(args) {
|
||||||
|
openDiffCalls.push(args)
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'TAB_CLOSED' }],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async closeTab() {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'TAB_CLOSED' }],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async closeAllDiffTabs() {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'OK' }],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridge = createIdeBridgeServer({ diffController })
|
||||||
|
const client = createTestClient()
|
||||||
|
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||||
|
|
||||||
|
await bridge.server.connect(serverTransport)
|
||||||
|
await client.connect(clientTransport)
|
||||||
|
|
||||||
|
const toolResult = await client.listTools()
|
||||||
|
expect(toolResult.tools.map(tool => tool.name)).toEqual([
|
||||||
|
'openDiff',
|
||||||
|
'close_tab',
|
||||||
|
'closeAllDiffTabs',
|
||||||
|
])
|
||||||
|
|
||||||
|
const openDiffResult = await client.callTool({
|
||||||
|
name: 'openDiff',
|
||||||
|
arguments: {
|
||||||
|
old_file_path: 'D:/vibe/claude-code/src/cli/print.ts',
|
||||||
|
new_file_path: 'D:/vibe/claude-code/src/cli/print.ts',
|
||||||
|
new_file_contents: 'new content',
|
||||||
|
tab_name: 'tab-1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(openDiffResult.content[0]).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
text: 'TAB_CLOSED',
|
||||||
|
})
|
||||||
|
expect(openDiffCalls).toHaveLength(1)
|
||||||
|
expect(openDiffCalls[0]?.tab_name).toBe('tab-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('forwards selection_changed notifications to the connected client', async () => {
|
||||||
|
const diffController: DiffController = {
|
||||||
|
async openDiff() {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'TAB_CLOSED' }],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async closeTab() {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'TAB_CLOSED' }],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async closeAllDiffTabs() {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'OK' }],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridge = createIdeBridgeServer({ diffController })
|
||||||
|
const client = createTestClient()
|
||||||
|
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||||
|
|
||||||
|
await bridge.server.connect(serverTransport)
|
||||||
|
await client.connect(clientTransport)
|
||||||
|
|
||||||
|
const notificationPromise = new Promise<z.infer<typeof SelectionChangedSchema>>(
|
||||||
|
resolve => {
|
||||||
|
client.setNotificationHandler(SelectionChangedSchema, notification => {
|
||||||
|
resolve(notification)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await bridge.notifySelectionChanged({
|
||||||
|
selection: {
|
||||||
|
start: { line: 4, character: 2 },
|
||||||
|
end: { line: 6, character: 0 },
|
||||||
|
},
|
||||||
|
text: 'selected text',
|
||||||
|
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
|
||||||
|
})
|
||||||
|
|
||||||
|
const notification = await notificationPromise
|
||||||
|
expect(notification.params.filePath).toBe(
|
||||||
|
'D:/vibe/claude-code/src/cli/print.ts',
|
||||||
|
)
|
||||||
|
expect(notification.params.text).toBe('selected text')
|
||||||
|
expect(notification.params.selection?.start.line).toBe(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
247
packages/vscode-ide-bridge/test/diffController.test.ts
Normal file
247
packages/vscode-ide-bridge/test/diffController.test.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
|
type FakeUri = {
|
||||||
|
scheme: string
|
||||||
|
fsPath: string
|
||||||
|
path: string
|
||||||
|
query: string
|
||||||
|
toString(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FakeDocument = {
|
||||||
|
uri: FakeUri
|
||||||
|
isDirty: boolean
|
||||||
|
lineCount: number
|
||||||
|
lineAt(index: number): { text: string }
|
||||||
|
getText(): string
|
||||||
|
setText(next: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeUri(
|
||||||
|
scheme: string,
|
||||||
|
fsPath: string,
|
||||||
|
query = '',
|
||||||
|
): FakeUri {
|
||||||
|
const normalizedFsPath = fsPath.replaceAll('\\', '/')
|
||||||
|
return {
|
||||||
|
scheme,
|
||||||
|
fsPath,
|
||||||
|
path: fsPath,
|
||||||
|
query,
|
||||||
|
toString() {
|
||||||
|
if (scheme === 'file') {
|
||||||
|
return `file://${normalizedFsPath}`
|
||||||
|
}
|
||||||
|
return `${scheme}:/${normalizedFsPath}${query ? `?${query}` : ''}`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeVscode() {
|
||||||
|
const documents = new Map<string, FakeDocument>()
|
||||||
|
const saveListeners = new Set<(document: FakeDocument) => void>()
|
||||||
|
const visibleEditorListeners = new Set<(editors: any[]) => void>()
|
||||||
|
const visibleTextEditors: any[] = []
|
||||||
|
|
||||||
|
function createDocument(uri: FakeUri, initialText = ''): FakeDocument {
|
||||||
|
let text = initialText
|
||||||
|
return {
|
||||||
|
uri,
|
||||||
|
isDirty: false,
|
||||||
|
get lineCount() {
|
||||||
|
return Math.max(text.split('\n').length, 1)
|
||||||
|
},
|
||||||
|
lineAt(index: number) {
|
||||||
|
return {
|
||||||
|
text: text.split('\n')[index] ?? '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getText() {
|
||||||
|
return text
|
||||||
|
},
|
||||||
|
setText(next: string) {
|
||||||
|
text = next
|
||||||
|
this.isDirty = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vscode = {
|
||||||
|
Uri: {
|
||||||
|
parse(value: string) {
|
||||||
|
const match = value.match(/^([a-z-]+):\/(.+?)(?:\?(.*))?$/i)
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Unsupported URI: ${value}`)
|
||||||
|
}
|
||||||
|
const [, scheme, path, query = ''] = match
|
||||||
|
return createFakeUri(
|
||||||
|
scheme,
|
||||||
|
decodeURIComponent(path),
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
file(filePath: string) {
|
||||||
|
return createFakeUri('file', filePath)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Range: class {
|
||||||
|
constructor(
|
||||||
|
public startLine: number,
|
||||||
|
public startCharacter: number,
|
||||||
|
public endLine: number,
|
||||||
|
public endCharacter: number,
|
||||||
|
) {}
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
registerTextDocumentContentProvider() {
|
||||||
|
return { dispose() {} }
|
||||||
|
},
|
||||||
|
onDidSaveTextDocument(handler: (document: FakeDocument) => void) {
|
||||||
|
saveListeners.add(handler)
|
||||||
|
return {
|
||||||
|
dispose() {
|
||||||
|
saveListeners.delete(handler)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async openTextDocument(uri: FakeUri) {
|
||||||
|
const key = uri.toString()
|
||||||
|
const existing = documents.get(key)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
const doc = createDocument(uri)
|
||||||
|
documents.set(key, doc)
|
||||||
|
return doc
|
||||||
|
},
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
visibleTextEditors,
|
||||||
|
tabGroups: {
|
||||||
|
all: [],
|
||||||
|
async close() {},
|
||||||
|
},
|
||||||
|
onDidChangeVisibleTextEditors(handler: (editors: any[]) => void) {
|
||||||
|
visibleEditorListeners.add(handler)
|
||||||
|
return {
|
||||||
|
dispose() {
|
||||||
|
visibleEditorListeners.delete(handler)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async showTextDocument(document: FakeDocument) {
|
||||||
|
const editor = {
|
||||||
|
document,
|
||||||
|
viewColumn: 1,
|
||||||
|
async edit(
|
||||||
|
callback: (editBuilder: { replace(range: unknown, text: string): void }) => void,
|
||||||
|
) {
|
||||||
|
callback({
|
||||||
|
replace(_range, text) {
|
||||||
|
document.setText(text)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (!visibleTextEditors.includes(editor)) {
|
||||||
|
visibleTextEditors.splice(0, visibleTextEditors.length, editor)
|
||||||
|
for (const listener of visibleEditorListeners) {
|
||||||
|
listener([...visibleTextEditors])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return editor
|
||||||
|
},
|
||||||
|
async showInformationMessage() {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commands: {
|
||||||
|
async executeCommand() {},
|
||||||
|
},
|
||||||
|
__documents: documents,
|
||||||
|
async __emitSave(document: FakeDocument) {
|
||||||
|
document.isDirty = false
|
||||||
|
for (const listener of saveListeners) {
|
||||||
|
listener(document)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return vscode
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDocument(
|
||||||
|
filePath: string,
|
||||||
|
attempts = 20,
|
||||||
|
): Promise<FakeDocument | undefined> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
const document = fakeVscode.__documents.get(
|
||||||
|
fakeVscode.Uri.file(filePath).toString(),
|
||||||
|
)
|
||||||
|
if (document) {
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeVscode = createFakeVscode()
|
||||||
|
mock.module('vscode', () => fakeVscode)
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fakeVscode.__documents.clear()
|
||||||
|
fakeVscode.window.visibleTextEditors.splice(
|
||||||
|
0,
|
||||||
|
fakeVscode.window.visibleTextEditors.length,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('diff controller', () => {
|
||||||
|
test('returns FILE_SAVED with the saved file contents', async () => {
|
||||||
|
const { createDiffController } = await import(
|
||||||
|
'../src/server/diffController.js'
|
||||||
|
)
|
||||||
|
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'claude-code-bridge-'))
|
||||||
|
const filePath = join(tempDir, 'sample.ts')
|
||||||
|
writeFileSync(filePath, 'const before = true\n')
|
||||||
|
|
||||||
|
const controller = createDiffController({
|
||||||
|
appendLine() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const resultPromise = controller.openDiff({
|
||||||
|
old_file_path: filePath,
|
||||||
|
new_file_path: filePath,
|
||||||
|
new_file_contents: 'const proposed = true\n',
|
||||||
|
tab_name: 'sample.ts',
|
||||||
|
})
|
||||||
|
|
||||||
|
const savedDocument = await waitForDocument(filePath)
|
||||||
|
expect(savedDocument).toBeDefined()
|
||||||
|
|
||||||
|
savedDocument?.setText('const saved = true\n')
|
||||||
|
await fakeVscode.__emitSave(savedDocument as FakeDocument)
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
resultPromise,
|
||||||
|
new Promise(resolve =>
|
||||||
|
setTimeout(() => resolve('timed-out'), 200),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'FILE_SAVED' },
|
||||||
|
{ type: 'text', text: 'const saved = true\n' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
40
packages/vscode-ide-bridge/test/lockfile.test.ts
Normal file
40
packages/vscode-ide-bridge/test/lockfile.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
buildLockfilePayload,
|
||||||
|
getLockfilePath,
|
||||||
|
} from '../src/server/lockfile.js'
|
||||||
|
|
||||||
|
describe('lockfile helpers', () => {
|
||||||
|
test('builds a ws-ide lockfile payload with auth token and workspace folders', () => {
|
||||||
|
const payload = buildLockfilePayload({
|
||||||
|
pid: 123,
|
||||||
|
ideName: 'VS Code',
|
||||||
|
workspaceFolders: ['D:/vibe/claude-code'],
|
||||||
|
authToken: 'token-123',
|
||||||
|
runningInWindows: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(payload.transport).toBe('ws')
|
||||||
|
expect(payload.authToken).toBe('token-123')
|
||||||
|
expect(payload.workspaceFolders).toEqual(['D:/vibe/claude-code'])
|
||||||
|
expect(payload.pid).toBe(123)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('derives the lockfile path from CLAUDE_CONFIG_DIR when provided', () => {
|
||||||
|
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = 'D:/tmp/claude-config'
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(getLockfilePath(4567)).toBe(
|
||||||
|
join('D:/tmp/claude-config', 'ide', '4567.lock'),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (originalConfigDir === undefined) {
|
||||||
|
delete process.env.CLAUDE_CONFIG_DIR
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
32
packages/vscode-ide-bridge/test/package.test.ts
Normal file
32
packages/vscode-ide-bridge/test/package.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
const packageRoot = join(import.meta.dir, '..')
|
||||||
|
const packageJsonPath = join(packageRoot, 'package.json')
|
||||||
|
|
||||||
|
describe('vscode-ide-bridge package', () => {
|
||||||
|
test('declares a VSCode extension entry', () => {
|
||||||
|
expect(existsSync(packageJsonPath)).toBe(true)
|
||||||
|
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||||
|
main?: string
|
||||||
|
engines?: { vscode?: string }
|
||||||
|
activationEvents?: string[]
|
||||||
|
dependencies?: Record<string, string>
|
||||||
|
devDependencies?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(packageJson.main).toBe('./dist/extension.js')
|
||||||
|
expect(packageJson.engines?.vscode).toBeDefined()
|
||||||
|
expect(packageJson.activationEvents).toContain('onStartupFinished')
|
||||||
|
expect(packageJson.dependencies).toMatchObject({
|
||||||
|
'@modelcontextprotocol/sdk': expect.any(String),
|
||||||
|
ws: expect.any(String),
|
||||||
|
})
|
||||||
|
expect(packageJson.devDependencies).toMatchObject({
|
||||||
|
'@types/bun': expect.any(String),
|
||||||
|
typescript: expect.any(String),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
71
packages/vscode-ide-bridge/test/packagePackaging.test.ts
Normal file
71
packages/vscode-ide-bridge/test/packagePackaging.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
type PackageJson = {
|
||||||
|
displayName?: string
|
||||||
|
publisher?: string
|
||||||
|
license?: string
|
||||||
|
scripts?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskConfig = {
|
||||||
|
label?: string
|
||||||
|
command?: string
|
||||||
|
args?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageRoot = join(import.meta.dir, '..')
|
||||||
|
const packageJsonPath = join(packageRoot, 'package.json')
|
||||||
|
const tasksJsonPath = join(packageRoot, '.vscode', 'tasks.json')
|
||||||
|
const vscodeIgnorePath = join(packageRoot, '.vscodeignore')
|
||||||
|
const readmePath = join(packageRoot, 'README.md')
|
||||||
|
|
||||||
|
describe('vscode-ide-bridge packaging workflow', () => {
|
||||||
|
test('declares the metadata and script needed to package a .vsix', () => {
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as PackageJson
|
||||||
|
|
||||||
|
expect(packageJson.displayName).toBe('Claude Code IDE Bridge')
|
||||||
|
expect(packageJson.publisher).toBe('claude-code-best')
|
||||||
|
expect(packageJson.license).toBeDefined()
|
||||||
|
expect(packageJson.scripts?.bundle).toBe(
|
||||||
|
'bun build ./src/extension.ts --outdir dist --target node --format esm --external vscode',
|
||||||
|
)
|
||||||
|
expect(packageJson.scripts?.package).toBe(
|
||||||
|
'bun run bundle && bunx @vscode/vsce package --no-dependencies --out dist/vscode-ide-bridge.vsix',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('declares a package-local task for building a .vsix', () => {
|
||||||
|
expect(existsSync(tasksJsonPath)).toBe(true)
|
||||||
|
|
||||||
|
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
|
||||||
|
tasks?: TaskConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageTask = tasksJson.tasks?.find(
|
||||||
|
item => item.label === 'Package VSCode IDE Bridge',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(packageTask).toBeDefined()
|
||||||
|
expect(packageTask?.command).toBe('bun')
|
||||||
|
expect(packageTask?.args).toEqual(['run', 'package'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('excludes development-only files from the packaged extension', () => {
|
||||||
|
expect(existsSync(vscodeIgnorePath)).toBe(true)
|
||||||
|
|
||||||
|
const contents = readFileSync(vscodeIgnorePath, 'utf8')
|
||||||
|
|
||||||
|
expect(contents).toContain('src/**')
|
||||||
|
expect(contents).toContain('test/**')
|
||||||
|
expect(contents).toContain('tsconfig.json')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the packaged README free of local absolute file links', () => {
|
||||||
|
const contents = readFileSync(readmePath, 'utf8')
|
||||||
|
|
||||||
|
expect(contents).not.toContain('](/')
|
||||||
|
expect(contents).not.toContain(':/')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
type LaunchConfig = {
|
||||||
|
name?: string
|
||||||
|
type?: string
|
||||||
|
request?: string
|
||||||
|
preLaunchTask?: string
|
||||||
|
args?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskConfig = {
|
||||||
|
label?: string
|
||||||
|
command?: string
|
||||||
|
args?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageRoot = join(import.meta.dir, '..')
|
||||||
|
const launchJsonPath = join(packageRoot, '.vscode', 'launch.json')
|
||||||
|
const tasksJsonPath = join(packageRoot, '.vscode', 'tasks.json')
|
||||||
|
|
||||||
|
describe('standalone package workspace workflow', () => {
|
||||||
|
test('declares a package-local extension host launch config', () => {
|
||||||
|
expect(existsSync(launchJsonPath)).toBe(true)
|
||||||
|
|
||||||
|
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
|
||||||
|
configurations?: LaunchConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = launchJson.configurations?.find(
|
||||||
|
item => item.name === 'Run VSCode IDE Bridge',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config).toBeDefined()
|
||||||
|
expect(config?.type).toBe('extensionHost')
|
||||||
|
expect(config?.request).toBe('launch')
|
||||||
|
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
|
||||||
|
expect(config?.args).toContain('--new-window')
|
||||||
|
expect(config?.args).toContain('--disable-extensions')
|
||||||
|
expect(config?.args).toContain(
|
||||||
|
'--extensionDevelopmentPath=${workspaceFolder}',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('declares a launch config that opens the claude-code workspace root', () => {
|
||||||
|
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
|
||||||
|
configurations?: LaunchConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = launchJson.configurations?.find(
|
||||||
|
item => item.name === 'Run VSCode IDE Bridge (Open Claude Code Root)',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config).toBeDefined()
|
||||||
|
expect(config?.type).toBe('extensionHost')
|
||||||
|
expect(config?.request).toBe('launch')
|
||||||
|
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
|
||||||
|
expect(config?.args).toContain('--new-window')
|
||||||
|
expect(config?.args).toContain('--disable-extensions')
|
||||||
|
expect(config?.args).toContain(
|
||||||
|
'--extensionDevelopmentPath=${workspaceFolder}',
|
||||||
|
)
|
||||||
|
expect(config?.args).toContain('${workspaceFolder}/../..')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('declares package-local build and test tasks', () => {
|
||||||
|
expect(existsSync(tasksJsonPath)).toBe(true)
|
||||||
|
|
||||||
|
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
|
||||||
|
tasks?: TaskConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildTask = tasksJson.tasks?.find(
|
||||||
|
item => item.label === 'Build VSCode IDE Bridge',
|
||||||
|
)
|
||||||
|
const testTask = tasksJson.tasks?.find(
|
||||||
|
item => item.label === 'Test VSCode IDE Bridge',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(buildTask).toBeDefined()
|
||||||
|
expect(buildTask?.command).toBe('bunx')
|
||||||
|
expect(buildTask?.args).toEqual(['tsc', '-p', 'tsconfig.json'])
|
||||||
|
|
||||||
|
expect(testTask).toBeDefined()
|
||||||
|
expect(testTask?.command).toBe('bun')
|
||||||
|
expect(testTask?.args).toEqual(['test', 'test'])
|
||||||
|
})
|
||||||
|
})
|
||||||
27
packages/vscode-ide-bridge/test/selectionPublisher.test.ts
Normal file
27
packages/vscode-ide-bridge/test/selectionPublisher.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { buildSelectionChangedParams } from '../src/server/selectionPublisher.js'
|
||||||
|
|
||||||
|
describe('selection publisher helpers', () => {
|
||||||
|
test('serializes a selected range with text and file path', () => {
|
||||||
|
const params = buildSelectionChangedParams({
|
||||||
|
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
|
||||||
|
text: 'const value = 1',
|
||||||
|
start: { line: 10, character: 2 },
|
||||||
|
end: { line: 10, character: 17 },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(params.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
|
||||||
|
expect(params.text).toBe('const value = 1')
|
||||||
|
expect(params.selection?.start.line).toBe(10)
|
||||||
|
expect(params.selection?.end.character).toBe(17)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps file context when there is no active selection', () => {
|
||||||
|
const params = buildSelectionChangedParams({
|
||||||
|
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(params.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
|
||||||
|
expect(params.selection).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { ServerWebSocketTransport } from '../src/server/serverWebSocketTransport.js'
|
||||||
|
|
||||||
|
class FakeWebSocket extends EventEmitter {
|
||||||
|
readyState = 1
|
||||||
|
sent: string[] = []
|
||||||
|
closed = false
|
||||||
|
|
||||||
|
send(data: string, callback?: (error?: Error) => void) {
|
||||||
|
this.sent.push(data)
|
||||||
|
callback?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true
|
||||||
|
this.emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('server web socket transport', () => {
|
||||||
|
test('forwards incoming JSON-RPC messages to the MCP server', async () => {
|
||||||
|
const socket = new FakeWebSocket()
|
||||||
|
const transport = new ServerWebSocketTransport(socket)
|
||||||
|
const messages: unknown[] = []
|
||||||
|
|
||||||
|
transport.onmessage = message => {
|
||||||
|
messages.push(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
await transport.start()
|
||||||
|
socket.emit(
|
||||||
|
'message',
|
||||||
|
Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'ping',
|
||||||
|
params: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(1)
|
||||||
|
expect(messages[0]).toEqual({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'ping',
|
||||||
|
params: {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('serializes outgoing JSON-RPC messages back to the websocket', async () => {
|
||||||
|
const socket = new FakeWebSocket()
|
||||||
|
const transport = new ServerWebSocketTransport(socket)
|
||||||
|
|
||||||
|
await transport.start()
|
||||||
|
await transport.send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
result: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(socket.sent).toHaveLength(1)
|
||||||
|
expect(JSON.parse(socket.sent[0] ?? 'null')).toEqual({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
result: {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
48
packages/vscode-ide-bridge/test/terminalEnvironment.test.ts
Normal file
48
packages/vscode-ide-bridge/test/terminalEnvironment.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
clearClaudeCodeIdePort,
|
||||||
|
setClaudeCodeIdePort,
|
||||||
|
} from '../src/server/terminalEnvironment.js'
|
||||||
|
|
||||||
|
type FakeEnvironmentVariableCollection = {
|
||||||
|
replaceCalls: Array<{ name: string; value: string }>
|
||||||
|
deleteCalls: string[]
|
||||||
|
replace(name: string, value: string): void
|
||||||
|
delete(name: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeCollection(): FakeEnvironmentVariableCollection {
|
||||||
|
return {
|
||||||
|
replaceCalls: [],
|
||||||
|
deleteCalls: [],
|
||||||
|
replace(name, value) {
|
||||||
|
this.replaceCalls.push({ name, value })
|
||||||
|
},
|
||||||
|
delete(name) {
|
||||||
|
this.deleteCalls.push(name)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('terminal environment sync', () => {
|
||||||
|
test('sets CLAUDE_CODE_SSE_PORT to the active bridge port', () => {
|
||||||
|
const collection = createFakeCollection()
|
||||||
|
|
||||||
|
setClaudeCodeIdePort(collection, 52075)
|
||||||
|
|
||||||
|
expect(collection.replaceCalls).toEqual([
|
||||||
|
{
|
||||||
|
name: 'CLAUDE_CODE_SSE_PORT',
|
||||||
|
value: '52075',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clears CLAUDE_CODE_SSE_PORT when the bridge stops', () => {
|
||||||
|
const collection = createFakeCollection()
|
||||||
|
|
||||||
|
clearClaudeCodeIdePort(collection)
|
||||||
|
|
||||||
|
expect(collection.deleteCalls).toEqual(['CLAUDE_CODE_SSE_PORT'])
|
||||||
|
})
|
||||||
|
})
|
||||||
61
packages/vscode-ide-bridge/test/vscodeWorkflow.test.ts
Normal file
61
packages/vscode-ide-bridge/test/vscodeWorkflow.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
type LaunchConfig = {
|
||||||
|
name?: string
|
||||||
|
type?: string
|
||||||
|
request?: string
|
||||||
|
preLaunchTask?: string
|
||||||
|
args?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskConfig = {
|
||||||
|
label?: string
|
||||||
|
command?: string
|
||||||
|
args?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceRoot = join(import.meta.dir, '..', '..', '..')
|
||||||
|
const launchJsonPath = join(workspaceRoot, '.vscode', 'launch.json')
|
||||||
|
const tasksJsonPath = join(workspaceRoot, '.vscode', 'tasks.json')
|
||||||
|
|
||||||
|
describe('VSCode IDE bridge developer workflow', () => {
|
||||||
|
test('declares a one-click extension host launch config', () => {
|
||||||
|
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
|
||||||
|
configurations?: LaunchConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = launchJson.configurations?.find(
|
||||||
|
item => item.name === 'Run VSCode IDE Bridge',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config).toBeDefined()
|
||||||
|
expect(config?.type).toBe('extensionHost')
|
||||||
|
expect(config?.request).toBe('launch')
|
||||||
|
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
|
||||||
|
expect(config?.args).toContain('--new-window')
|
||||||
|
expect(config?.args).toContain('--disable-extensions')
|
||||||
|
expect(config?.args).toContain(
|
||||||
|
'--extensionDevelopmentPath=${workspaceFolder}/packages/vscode-ide-bridge',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('declares a build task for the bridge package', () => {
|
||||||
|
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
|
||||||
|
tasks?: TaskConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = tasksJson.tasks?.find(
|
||||||
|
item => item.label === 'Build VSCode IDE Bridge',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(task).toBeDefined()
|
||||||
|
expect(task?.command).toBe('bunx')
|
||||||
|
expect(task?.args).toEqual([
|
||||||
|
'tsc',
|
||||||
|
'-p',
|
||||||
|
'packages/vscode-ide-bridge/tsconfig.json',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
41
packages/vscode-ide-bridge/test/workspaceInfo.test.ts
Normal file
41
packages/vscode-ide-bridge/test/workspaceInfo.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
getActiveSelectionSnapshot,
|
||||||
|
getWorkspaceFolderPaths,
|
||||||
|
} from '../src/server/workspaceInfo.js'
|
||||||
|
|
||||||
|
describe('workspace info helpers', () => {
|
||||||
|
test('collects workspace folder fs paths', () => {
|
||||||
|
expect(
|
||||||
|
getWorkspaceFolderPaths([
|
||||||
|
{ uri: { fsPath: 'D:/vibe/claude-code' } },
|
||||||
|
{ uri: { fsPath: 'D:/vibe/another-project' } },
|
||||||
|
]),
|
||||||
|
).toEqual(['D:/vibe/claude-code', 'D:/vibe/another-project'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('extracts the active editor selection text and file path', () => {
|
||||||
|
const snapshot = getActiveSelectionSnapshot({
|
||||||
|
document: {
|
||||||
|
uri: { fsPath: 'D:/vibe/claude-code/src/cli/print.ts' },
|
||||||
|
getText(selection: unknown) {
|
||||||
|
expect(selection).toEqual({
|
||||||
|
start: { line: 3, character: 1 },
|
||||||
|
end: { line: 5, character: 0 },
|
||||||
|
isEmpty: false,
|
||||||
|
})
|
||||||
|
return 'selected lines'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
start: { line: 3, character: 1 },
|
||||||
|
end: { line: 5, character: 0 },
|
||||||
|
isEmpty: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(snapshot.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
|
||||||
|
expect(snapshot.text).toBe('selected lines')
|
||||||
|
expect(snapshot.selection?.start.line).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
19
packages/vscode-ide-bridge/tsconfig.json
Normal file
19
packages/vscode-ide-bridge/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": [
|
||||||
|
"bun"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user