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
@@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project" import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event" import { useEvent } from "@tui/context/event"
import { useEditorContext } from "@tui/context/editor" import { useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema" import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store" import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind" import { useKeybind } from "@tui/context/keybind"
@@ -84,6 +84,18 @@ function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha) return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
} }
function getEditorSelectionKey(selection: EditorSelection) {
return [
selection.filePath,
selection.text,
selection.source ?? "",
selection.selection.start.line,
selection.selection.start.character,
selection.selection.end.line,
selection.selection.end.character,
].join("-")
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) { export function Prompt(props: PromptProps) {
@@ -135,6 +147,7 @@ export function Prompt(props: PromptProps) {
if (!file) return if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
}) })
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>() const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider) const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right)) const hasRightContent = createMemo(() => Boolean(props.right))
@@ -748,36 +761,38 @@ export function Prompt(props: PromptProps) {
const currentMode = store.mode const currentMode = store.mode
const variant = local.model.variant.current() const variant = local.model.variant.current()
const editorSelection = fileContextEnabled() ? editor.selection() : undefined const editorSelection = fileContextEnabled() ? editor.selection() : undefined
const editorParts = editorSelection const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
? [ const editorParts =
{ editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey
id: PartID.ascending(), ? [
type: "text" as const, {
text: (() => { id: PartID.ascending(),
const start = editorSelection.selection.start type: "text" as const,
const end = editorSelection.selection.end text: (() => {
const start = editorSelection.selection.start
const end = editorSelection.selection.end
let text = "" let text = ""
if (start.line === end.line && start.character === end.character) { if (start.line === end.line && start.character === end.character) {
text = `Note: The user opened the file "${editorSelection.filePath}".` text = `Note: The user opened the file "${editorSelection.filePath}".`
} else if (start.line === end.line) { } else if (start.line === end.line) {
text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
} else { } else {
text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
} }
return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n` return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
})(), })(),
synthetic: true, synthetic: true,
metadata: { metadata: {
kind: "editor_context", kind: "editor_context",
source: editorSelection.source ?? "editor", source: editorSelection.source ?? "editor",
filePath: editorSelection.filePath, filePath: editorSelection.filePath,
selection: editorSelection.selection, selection: editorSelection.selection,
},
}, },
}, ]
] : []
: []
if (store.mode === "shell") { if (store.mode === "shell") {
void sdk.client.session.shell({ void sdk.client.session.shell({
@@ -840,7 +855,7 @@ export function Prompt(props: PromptProps) {
], ],
}) })
.catch(() => {}) .catch(() => {})
editor.clearSelection() lastSubmittedEditorSelectionKey = editorSelectionKey
} }
history.append({ history.append({
...store.prompt, ...store.prompt,
@@ -75,8 +75,9 @@ type EditorLockFile = {
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({ export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext", name: "EditorContext",
init: () => { init: (props: { WebSocketImpl?: typeof WebSocket }) => {
const mentionListeners = new Set<(mention: EditorMention) => void>() const mentionListeners = new Set<(mention: EditorMention) => void>()
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected" status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined selection: EditorSelection | undefined
@@ -87,138 +88,160 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server: undefined, server: undefined,
}) })
onMount(() => { let socket: WebSocket | undefined
let socket: WebSocket | undefined let closed = false
let closed = false let reconnect: ReturnType<typeof setTimeout> | undefined
let reconnect: ReturnType<typeof setTimeout> | undefined let attempt = 0
let attempt = 0 let requestID = 0
let requestID = 0 let zedSelection: Promise<void> | undefined
let zedSelection: Promise<void> | undefined let lastZedSelectionKey: string | undefined
let lastZedSelectionKey: string | undefined let directory = process.cwd()
const pending = new Map<number, string>() const pending = new Map<number, string>()
const send = (payload: JsonRpcMessage) => { const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return if (!socket || socket.readyState !== 1) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload })) 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) => { setStore("status", "connecting")
requestID += 1 const current = openEditorSocket(connection, WebSocketImpl)
pending.set(requestID, method) socket = current
send({ id: requestID, method, params })
}
const scheduleReconnect = () => { current.addEventListener("open", () => {
if (closed) return if (socket !== current) {
if (reconnect) clearTimeout(reconnect) current.close()
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()
return 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") setStore("status", "connecting")
const current = openEditorSocket(connection) scheduleReconnect()
socket = current })
}
current.addEventListener("open", () => { const scheduleReconnect = () => {
if (socket !== current) { if (closed) return
current.close() if (reconnect) clearTimeout(reconnect)
return attempt += 1
} const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
reconnect = setTimeout(connect, delay)
}
attempt = 0 const scheduleZedPoll = () => {
setStore("status", "connected") if (closed) return
request("initialize", { if (reconnect) clearTimeout(reconnect)
protocolVersion: MCP_PROTOCOL_VERSION, reconnect = setTimeout(connect, 1000)
capabilities: {}, }
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
current.addEventListener("message", (event) => { const reconnectWithDirectory = (nextDirectory?: string) => {
const message = parseMessage(event.data) const resolved = nextDirectory || process.cwd()
if (!message) return if (directory === resolved) return
const selection = directory = resolved
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined attempt = 0
if (selection?.success) { pending.clear()
setStore("selection", { ...selection.data, source: "websocket" }) lastZedSelectionKey = undefined
return if (reconnect) clearTimeout(reconnect)
} reconnect = undefined
if (socket) {
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined const current = socket
if (mention?.success) { socket = undefined
mentionListeners.forEach((listener) => listener(mention.data)) current.close()
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()
})
} }
setStore("status", "disabled")
setStore("selection", undefined)
setStore("server", undefined)
connect()
}
onMount(() => {
connect() connect()
onCleanup(() => { onCleanup(() => {
@@ -230,7 +253,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return { return {
enabled() { enabled() {
return Boolean(resolveEditorConnection() || resolveZedDbPath()) return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
}, },
connected() { connected() {
return store.status === "connected" return store.status === "connected"
@@ -248,6 +271,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server() { server() {
return store.server return store.server
}, },
reconnect(directory?: string) {
setStore("selection", undefined)
reconnectWithDirectory(directory)
},
} }
}, },
}) })
@@ -260,8 +287,16 @@ function parsePort(value: string | undefined) {
return parsed return parsed
} }
function resolveEditorConnection(): EditorConnection | undefined { function resolveEditorConnection(directory: string): EditorConnection | undefined {
const lock = resolveEditorLockFile() 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) { if (lock) {
return { return {
url: `ws://127.0.0.1:${lock.port}`, url: `ws://127.0.0.1:${lock.port}`,
@@ -269,16 +304,9 @@ function resolveEditorConnection(): EditorConnection | undefined {
source: `lock:${lock.port}`, 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") const directory = path.join(os.homedir(), ".claude", "ide")
let entries: string[] let entries: string[]
@@ -288,10 +316,9 @@ function resolveEditorLockFile() {
return return
} }
const cwd = process.cwd() // longest workspace folder that contains the active session directory; 0 if none match
// longest workspace folder that contains cwd; 0 if none match
const bestMatchLength = (lock: EditorLockFile) => 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 const locks = entries
.filter((entry) => entry.endsWith(".lock")) .filter((entry) => entry.endsWith(".lock"))
.map((entry) => readEditorLockFile(path.join(directory, entry))) .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 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
} }
function openEditorSocket(connection: EditorConnection) { function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
if (!connection.authToken) return new WebSocket(connection.url) if (!connection.authToken) return new WebSocketImpl(connection.url)
return new WebSocket(connection.url, { return new WebSocketImpl(connection.url, {
headers: { headers: {
"x-claude-code-ide-authorization": connection.authToken, "x-claude-code-ide-authorization": connection.authToken,
}, },
@@ -50,6 +50,7 @@ import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill" import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import { useCommandDialog } from "@tui/component/dialog-command" import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog" import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind" import { useKeybind } from "@tui/context/keybind"
@@ -179,6 +180,7 @@ export function Session() {
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast() const toast = useToast()
const sdk = useSDK() const sdk = useSDK()
const editor = useEditorContext()
createEffect(() => { createEffect(() => {
const sessionID = route.sessionID const sessionID = route.sessionID
@@ -206,6 +208,7 @@ export function Session() {
await sync.bootstrap({ fatal: false }) await sync.bootstrap({ fatal: false })
} catch {} } catch {}
} }
editor.reconnect(result.data.directory)
await sync.session.sync(sessionID) await sync.session.sync(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => { })().catch((error) => {
@@ -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()
})
+46
View File
@@ -0,0 +1,46 @@
export class FakeWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readyState = FakeWebSocket.CONNECTING
closed = false
sent: string[] = []
listeners = new Map<string, Set<(event: { data?: unknown }) => void>>()
constructor(
readonly url: string,
readonly options?: { headers?: Record<string, string> },
) {}
addEventListener(type: string, listener: (event: { data?: unknown }) => void) {
const current = this.listeners.get(type) ?? new Set<(event: { data?: unknown }) => void>()
current.add(listener)
this.listeners.set(type, current)
}
send(data: string) {
this.sent.push(data)
}
close() {
if (this.readyState === FakeWebSocket.CLOSED) return
this.closed = true
this.readyState = FakeWebSocket.CLOSED
this.emit("close", {})
}
open() {
this.readyState = FakeWebSocket.OPEN
this.emit("open", {})
}
message(data: unknown) {
this.emit("message", { data })
}
emit(type: string, event: { data?: unknown }) {
this.listeners.get(type)?.forEach((listener) => listener(event))
}
}