feat: add VS Code IDE bridge extension

This commit is contained in:
suger
2026-04-09 01:26:18 +08:00
parent 7f694168d0
commit 22480302c3
36 changed files with 3659 additions and 6 deletions

View 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)
})
})

View 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()
})
})

View File

@@ -0,0 +1,39 @@
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(
'D:\\tmp\\claude-config\\ide\\4567.lock',
)
} finally {
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
}
})
})

View 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),
})
})
})

View 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(':/')
})
})

View File

@@ -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'])
})
})

View 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()
})
})

View File

@@ -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: {},
})
})
})

View 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'])
})
})

View 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',
])
})
})

View 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)
})
})