fix(core): reconnect editor context for session directory (#24984)

This commit is contained in:
James Long
2026-04-29 15:11:44 -04:00
committed by GitHub
parent c480006554
commit 293877cb7e
6 changed files with 480 additions and 165 deletions

View File

@@ -0,0 +1,224 @@
import { mkdir, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterEach, expect, spyOn, test } from "bun:test"
import { createRoot } from "solid-js"
import { EditorContextProvider, useEditorContext } from "../../../src/cli/cmd/tui/context/editor"
import { tmpdir } from "../../fixture/fixture"
import { FakeWebSocket } from "../../lib/websocket"
const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT
const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT
afterEach(() => {
process.env.CLAUDE_CODE_SSE_PORT = originalClaudePort
process.env.OPENCODE_EDITOR_SSE_PORT = originalOpencodePort
})
function nextTick() {
return new Promise<void>((resolve) => queueMicrotask(resolve))
}
function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
let editor!: ReturnType<typeof useEditorContext>
let dispose!: () => void
createRoot((nextDispose) => {
dispose = nextDispose
const Consumer = () => {
editor = useEditorContext()
return null
}
return (
<EditorContextProvider WebSocketImpl={WebSocketImpl}>
<Consumer />
</EditorContextProvider>
)
})
return {
editor,
dispose,
}
}
function createWebSocketImpl(...sockets: FakeWebSocket[]) {
let index = 0
return class {
constructor(url: string, options?: { headers?: Record<string, string> }) {
const socket = sockets[index]
index += 1
expect(socket).toBeDefined()
expect(url).toBe(socket!.url)
expect(options).toEqual(socket!.options)
return socket as unknown as object
}
} as unknown as typeof WebSocket
}
test("useEditorContext reconnect switches editor server by session directory", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const sessionDirectory = path.join(tmp.path, "session")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
await mkdir(startupDirectory, { recursive: true })
await mkdir(sessionDirectory, { recursive: true })
await mkdir(ideDirectory, { recursive: true })
await writeFile(
path.join(ideDirectory, "3001.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [startupDirectory],
}),
)
await writeFile(
path.join(ideDirectory, "3002.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [sessionDirectory],
}),
)
process.env.CLAUDE_CODE_SSE_PORT = undefined
process.env.OPENCODE_EDITOR_SSE_PORT = undefined
spyOn(process, "cwd").mockImplementation(() => startupDirectory)
spyOn(os, "homedir").mockImplementation(() => tmp.path)
const firstSocket = new FakeWebSocket("ws://127.0.0.1:3001")
const secondSocket = new FakeWebSocket("ws://127.0.0.1:3002")
const mounted = mountEditorContext(createWebSocketImpl(firstSocket, secondSocket))
await nextTick()
expect(firstSocket.closed).toBeFalse()
mounted.editor.reconnect(sessionDirectory)
await nextTick()
expect(firstSocket.closed).toBeTrue()
expect(secondSocket.closed).toBeFalse()
mounted.dispose()
})
test("useEditorContext favors configured port over lock files", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
await mkdir(startupDirectory, { recursive: true })
await mkdir(ideDirectory, { recursive: true })
await writeFile(
path.join(ideDirectory, "3001.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [startupDirectory],
}),
)
process.env.CLAUDE_CODE_SSE_PORT = "4010"
process.env.OPENCODE_EDITOR_SSE_PORT = undefined
spyOn(process, "cwd").mockImplementation(() => startupDirectory)
spyOn(os, "homedir").mockImplementation(() => tmp.path)
const socket = new FakeWebSocket("ws://127.0.0.1:4010")
const mounted = mountEditorContext(createWebSocketImpl(socket))
await nextTick()
expect(socket.closed).toBeFalse()
mounted.dispose()
})
test("useEditorContext resets selection when reconnecting", async () => {
await using tmp = await tmpdir()
const startupDirectory = path.join(tmp.path, "startup")
const ideDirectory = path.join(tmp.path, ".claude", "ide")
await mkdir(startupDirectory, { recursive: true })
await mkdir(ideDirectory, { recursive: true })
await writeFile(
path.join(ideDirectory, "3001.lock"),
JSON.stringify({
transport: "ws",
workspaceFolders: [startupDirectory],
}),
)
process.env.CLAUDE_CODE_SSE_PORT = undefined
process.env.OPENCODE_EDITOR_SSE_PORT = undefined
spyOn(process, "cwd").mockImplementation(() => startupDirectory)
spyOn(os, "homedir").mockImplementation(() => tmp.path)
const socket = new FakeWebSocket("ws://127.0.0.1:3001")
const mounted = mountEditorContext(createWebSocketImpl(socket))
await nextTick()
expect(socket.closed).toBeFalse()
expect(mounted.editor.selection()).toBeUndefined()
expect(mounted.editor.connected()).toBeFalse()
socket.open()
socket.message(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
protocolVersion: "2025-11-25",
serverInfo: { name: "test", version: "0.0.0" },
},
}),
)
socket.message(
JSON.stringify({
jsonrpc: "2.0",
method: "selection_changed",
params: {
text: "foo",
filePath: path.join(startupDirectory, "file.ts"),
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
},
}),
)
expect(mounted.editor.connected()).toBeTrue()
expect(mounted.editor.server()).toEqual({
protocolVersion: "2025-11-25",
serverInfo: { name: "test", version: "0.0.0" },
})
expect(mounted.editor.selection()).toEqual({
text: "foo",
filePath: path.join(startupDirectory, "file.ts"),
source: "websocket",
selection: {
start: { line: 1, character: 1 },
end: { line: 1, character: 4 },
},
})
mounted.editor.reconnect(startupDirectory)
expect(socket.closed).toBeFalse()
expect(mounted.editor.connected()).toBeTrue()
expect(mounted.editor.selection()).toBeUndefined()
mounted.dispose()
})
test("useEditorContext connects with OPENCODE_EDITOR_SSE_PORT", async () => {
await using tmp = await tmpdir()
process.env.CLAUDE_CODE_SSE_PORT = undefined
process.env.OPENCODE_EDITOR_SSE_PORT = "4020"
spyOn(process, "cwd").mockImplementation(() => tmp.path)
const socket = new FakeWebSocket("ws://127.0.0.1:4020")
const mounted = mountEditorContext(createWebSocketImpl(socket))
await nextTick()
expect(socket.closed).toBeFalse()
mounted.dispose()
})