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

@@ -75,8 +75,9 @@ type EditorLockFile = {
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext",
init: () => {
init: (props: { WebSocketImpl?: typeof WebSocket }) => {
const mentionListeners = new Set<(mention: EditorMention) => void>()
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined
@@ -87,138 +88,160 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server: undefined,
})
onMount(() => {
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
const pending = new Map<number, string>()
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
let directory = process.cwd()
const pending = new Map<number, string>()
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== 1) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
const connect = () => {
if (closed) return
const connection = resolveEditorConnection(directory)
if (!connection) {
const dbPath = resolveZedDbPath()
if (!dbPath) {
setStore("status", "disabled")
scheduleReconnect()
return
}
zedSelection ??= resolveZedSelection(dbPath, directory)
.then((result) => {
if (closed || socket) return
if (result.type === "unavailable") return
const selection = result.type === "selection" ? result.selection : undefined
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setStore("selection", selection)
setStore("status", selection ? "connected" : "disabled")
}
})
.catch(() => {
// Keep the last known Zed selection for transient polling failures.
})
.finally(() => {
zedSelection = undefined
})
scheduleZedPoll()
return
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
setStore("status", "connecting")
const current = openEditorSocket(connection, WebSocketImpl)
socket = current
const scheduleReconnect = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
reconnect = setTimeout(connect, delay)
}
const scheduleZedPoll = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, 1000)
}
const connect = () => {
if (closed) return
const connection = resolveEditorConnection()
if (!connection) {
const dbPath = resolveZedDbPath()
if (!dbPath) {
setStore("status", "disabled")
scheduleReconnect()
return
}
zedSelection ??= resolveZedSelection(dbPath)
.then((result) => {
if (closed || socket) return
if (result.type === "unavailable") return
const selection = result.type === "selection" ? result.selection : undefined
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setStore("selection", selection)
setStore("status", selection ? "connected" : "disabled")
}
})
.catch(() => {
// Keep the last known Zed selection for transient polling failures.
})
.finally(() => {
zedSelection = undefined
})
scheduleZedPoll()
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", { ...selection.data, source: "websocket" })
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
const current = openEditorSocket(connection)
socket = current
scheduleReconnect()
})
}
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
const scheduleReconnect = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
reconnect = setTimeout(connect, delay)
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
const scheduleZedPoll = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, 1000)
}
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const reconnectWithDirectory = (nextDirectory?: string) => {
const resolved = nextDirectory || process.cwd()
if (directory === resolved) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", { ...selection.data, source: "websocket" })
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
scheduleReconnect()
})
directory = resolved
attempt = 0
pending.clear()
lastZedSelectionKey = undefined
if (reconnect) clearTimeout(reconnect)
reconnect = undefined
if (socket) {
const current = socket
socket = undefined
current.close()
}
setStore("status", "disabled")
setStore("selection", undefined)
setStore("server", undefined)
connect()
}
onMount(() => {
connect()
onCleanup(() => {
@@ -230,7 +253,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return {
enabled() {
return Boolean(resolveEditorConnection() || resolveZedDbPath())
return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
},
connected() {
return store.status === "connected"
@@ -248,6 +271,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server() {
return store.server
},
reconnect(directory?: string) {
setStore("selection", undefined)
reconnectWithDirectory(directory)
},
}
},
})
@@ -260,8 +287,16 @@ function parsePort(value: string | undefined) {
return parsed
}
function resolveEditorConnection(): EditorConnection | undefined {
const lock = resolveEditorLockFile()
function resolveEditorConnection(directory: string): EditorConnection | undefined {
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
if (port) {
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
const lock = resolveEditorLockFile(directory)
if (lock) {
return {
url: `ws://127.0.0.1:${lock.port}`,
@@ -269,16 +304,9 @@ function resolveEditorConnection(): EditorConnection | undefined {
source: `lock:${lock.port}`,
}
}
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
if (!port) return
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
function resolveEditorLockFile() {
function resolveEditorLockFile(activeDirectory: string) {
const directory = path.join(os.homedir(), ".claude", "ide")
let entries: string[]
@@ -288,10 +316,9 @@ function resolveEditorLockFile() {
return
}
const cwd = process.cwd()
// longest workspace folder that contains cwd; 0 if none match
// longest workspace folder that contains the active session directory; 0 if none match
const bestMatchLength = (lock: EditorLockFile) =>
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, cwd)))
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory)))
const locks = entries
.filter((entry) => entry.endsWith(".lock"))
.map((entry) => readEditorLockFile(path.join(directory, entry)))
@@ -343,10 +370,10 @@ function pathContainsLength(parent: string, child: string) {
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
}
function openEditorSocket(connection: EditorConnection) {
if (!connection.authToken) return new WebSocket(connection.url)
function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
if (!connection.authToken) return new WebSocketImpl(connection.url)
return new WebSocket(connection.url, {
return new WebSocketImpl(connection.url, {
headers: {
"x-claude-code-ide-authorization": connection.authToken,
},