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,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()
|
||||||
|
})
|
||||||
@@ -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