fix(core): reconnect editor context for session directory (#24984)
This commit is contained in:
@@ -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,7 +761,9 @@ 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(),
|
id: PartID.ascending(),
|
||||||
@@ -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,7 +88,6 @@ 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
|
||||||
@@ -95,10 +95,11 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
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 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,24 +109,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
send({ id: requestID, method, params })
|
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 scheduleZedPoll = () => {
|
|
||||||
if (closed) return
|
|
||||||
if (reconnect) clearTimeout(reconnect)
|
|
||||||
reconnect = setTimeout(connect, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
|
|
||||||
const connection = resolveEditorConnection()
|
const connection = resolveEditorConnection(directory)
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
const dbPath = resolveZedDbPath()
|
const dbPath = resolveZedDbPath()
|
||||||
if (!dbPath) {
|
if (!dbPath) {
|
||||||
@@ -133,7 +120,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
zedSelection ??= resolveZedSelection(dbPath)
|
zedSelection ??= resolveZedSelection(dbPath, directory)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (closed || socket) return
|
if (closed || socket) return
|
||||||
if (result.type === "unavailable") return
|
if (result.type === "unavailable") return
|
||||||
@@ -156,7 +143,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
}
|
}
|
||||||
|
|
||||||
setStore("status", "connecting")
|
setStore("status", "connecting")
|
||||||
const current = openEditorSocket(connection)
|
const current = openEditorSocket(connection, WebSocketImpl)
|
||||||
socket = current
|
socket = current
|
||||||
|
|
||||||
current.addEventListener("open", () => {
|
current.addEventListener("open", () => {
|
||||||
@@ -219,6 +206,42 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
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()
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user