|
|
@@ -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>()
|
|
|
-
|
|
|
- const send = (payload: JsonRpcMessage) => {
|
|
|
- if (!socket || socket.readyState !== WebSocket.OPEN) return
|
|
|
- socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
|
|
|
- }
|
|
|
+ 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 !== 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 request = (method: string, params?: unknown) => {
|
|
|
+ requestID += 1
|
|
|
+ pending.set(requestID, method)
|
|
|
+ send({ id: requestID, method, params })
|
|
|
+ }
|
|
|
|
|
|
- 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 connect = () => {
|
|
|
+ if (closed) return
|
|
|
|
|
|
- const scheduleZedPoll = () => {
|
|
|
- if (closed) return
|
|
|
- if (reconnect) clearTimeout(reconnect)
|
|
|
- reconnect = setTimeout(connect, 1000)
|
|
|
+ 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 connect = () => {
|
|
|
- if (closed) return
|
|
|
+ setStore("status", "connecting")
|
|
|
+ const current = openEditorSocket(connection, WebSocketImpl)
|
|
|
+ socket = current
|
|
|
|
|
|
- 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
|
|
|
}
|
|
|
|
|
|
- setStore("status", "connecting")
|
|
|
- const current = openEditorSocket(connection)
|
|
|
- socket = current
|
|
|
-
|
|
|
- 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" },
|
|
|
- })
|
|
|
+ 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("message", (event) => {
|
|
|
+ const message = parseMessage(event.data)
|
|
|
+ if (!message) return
|
|
|
|
|
|
- current.addEventListener("close", () => {
|
|
|
- if (socket !== current) return
|
|
|
+ const selection =
|
|
|
+ message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
|
|
|
+ if (selection?.success) {
|
|
|
+ setStore("selection", { ...selection.data, source: "websocket" })
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- socket = undefined
|
|
|
- pending.clear()
|
|
|
- if (closed) return
|
|
|
+ const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
|
|
|
+ if (mention?.success) {
|
|
|
+ mentionListeners.forEach((listener) => listener(mention.data))
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- setStore("status", "connecting")
|
|
|
- scheduleReconnect()
|
|
|
- })
|
|
|
+ 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()
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 reconnectWithDirectory = (nextDirectory?: string) => {
|
|
|
+ const resolved = nextDirectory || process.cwd()
|
|
|
+ if (directory === resolved) return
|
|
|
+
|
|
|
+ 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,
|
|
|
},
|