fix(core): better state handling of editor context (#25911)
This commit is contained in:
@@ -173,8 +173,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))))
|
||||||
})
|
})
|
||||||
const [editorContextHover, setEditorContextHover] = createSignal(false)
|
const editorContextLabelState = createMemo(() => editor.labelState())
|
||||||
let lastSubmittedEditorSelectionKey: string | undefined
|
|
||||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||||
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
|
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
|
||||||
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
|
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
|
||||||
@@ -916,9 +915,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
// Capture mode before it gets reset
|
// Capture mode before it gets reset
|
||||||
const currentMode = store.mode
|
const currentMode = store.mode
|
||||||
const editorSelection = editorContext()
|
const editorSelection = editorContext()
|
||||||
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
|
|
||||||
const editorParts =
|
const editorParts =
|
||||||
editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey
|
editorSelection && editor.labelState() === "pending"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: PartID.ascending(),
|
id: PartID.ascending(),
|
||||||
@@ -996,7 +994,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
lastSubmittedEditorSelectionKey = currentEditorSelectionKey
|
if (editorParts.length > 0) editor.markSelectionSent()
|
||||||
}
|
}
|
||||||
history.append({
|
history.append({
|
||||||
...store.prompt,
|
...store.prompt,
|
||||||
@@ -1011,13 +1009,15 @@ export function Prompt(props: PromptProps) {
|
|||||||
props.onSubmit?.()
|
props.onSubmit?.()
|
||||||
|
|
||||||
// temporary hack to make sure the message is sent
|
// temporary hack to make sure the message is sent
|
||||||
if (!props.sessionID)
|
if (!props.sessionID) {
|
||||||
|
if (editorParts.length > 0) editor.preserveSelectionFromNewSession()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "session",
|
type: "session",
|
||||||
sessionID,
|
sessionID,
|
||||||
})
|
})
|
||||||
}, 50)
|
}, 50)
|
||||||
|
}
|
||||||
input.clear()
|
input.clear()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1608,16 +1608,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
</Switch>
|
</Switch>
|
||||||
<Show when={status().type !== "retry"}>
|
<Show when={status().type !== "retry"}>
|
||||||
<box gap={2} flexDirection="row">
|
<box gap={2} flexDirection="row">
|
||||||
<Show when={editorFileLabelDisplay()}>
|
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<text
|
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
|
||||||
fg={theme.secondary}
|
|
||||||
onMouseOver={() => setEditorContextHover(true)}
|
|
||||||
onMouseOut={() => setEditorContextHover(false)}
|
|
||||||
onMouseUp={dismissEditorContext}
|
|
||||||
>
|
|
||||||
{editorContextHover() ? `x ${file()}` : file()}
|
|
||||||
</text>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const EditorServerInfoSchema = z.object({
|
|||||||
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
|
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
|
||||||
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
|
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
|
||||||
export type EditorMention = z.infer<typeof EditorMentionSchema>
|
export type EditorMention = z.infer<typeof EditorMentionSchema>
|
||||||
|
export type EditorLabelState = "pending" | "sent" | "none"
|
||||||
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
|
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
|
||||||
|
|
||||||
type EditorConnection = {
|
type EditorConnection = {
|
||||||
@@ -111,10 +112,12 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
status: "disabled" | "connecting" | "connected"
|
status: "disabled" | "connecting" | "connected"
|
||||||
selection: EditorSelection | undefined
|
selection: EditorSelection | undefined
|
||||||
|
selectionSent: boolean
|
||||||
server: EditorServerInfo | undefined
|
server: EditorServerInfo | undefined
|
||||||
}>({
|
}>({
|
||||||
status: "disabled",
|
status: "disabled",
|
||||||
selection: undefined,
|
selection: undefined,
|
||||||
|
selectionSent: false,
|
||||||
server: undefined,
|
server: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -126,8 +129,24 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
let zedSelection: Promise<void> | undefined
|
let zedSelection: Promise<void> | undefined
|
||||||
let lastZedSelectionKey: string | undefined
|
let lastZedSelectionKey: string | undefined
|
||||||
let directory = process.cwd()
|
let directory = process.cwd()
|
||||||
|
let preserveSelectionOnReconnect = false
|
||||||
const pending = new Map<number, string>()
|
const pending = new Map<number, string>()
|
||||||
|
|
||||||
|
const setSelection = (selection: EditorSelection | undefined) => {
|
||||||
|
const changed = editorSelectionKey(selection) !== editorSelectionKey(store.selection)
|
||||||
|
setStore("selection", selection)
|
||||||
|
if (changed) setStore("selectionSent", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSelectionForReconnect = (options?: { resetZedSelectionKey?: boolean }) => {
|
||||||
|
if (preserveSelectionOnReconnect) {
|
||||||
|
preserveSelectionOnReconnect = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (options?.resetZedSelectionKey) lastZedSelectionKey = undefined
|
||||||
|
setSelection(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
const send = (payload: JsonRpcMessage) => {
|
const send = (payload: JsonRpcMessage) => {
|
||||||
if (!socket || socket.readyState !== 1) return
|
if (!socket || socket.readyState !== 1) return
|
||||||
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
|
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
|
||||||
@@ -158,7 +177,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
const key = editorSelectionKey(selection)
|
const key = editorSelectionKey(selection)
|
||||||
if (key !== lastZedSelectionKey) {
|
if (key !== lastZedSelectionKey) {
|
||||||
lastZedSelectionKey = key
|
lastZedSelectionKey = key
|
||||||
setStore("selection", selection)
|
setSelection(selection)
|
||||||
setStore("status", selection ? "connected" : "disabled")
|
setStore("status", selection ? "connected" : "disabled")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -198,7 +217,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
const selection =
|
const selection =
|
||||||
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
|
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
|
||||||
if (selection?.success) {
|
if (selection?.success) {
|
||||||
setStore("selection", { ...selection.data, source: "websocket" })
|
setSelection({ ...selection.data, source: "websocket" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,12 +271,13 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
|
|
||||||
const reconnectWithDirectory = (nextDirectory?: string) => {
|
const reconnectWithDirectory = (nextDirectory?: string) => {
|
||||||
const resolved = nextDirectory || process.cwd()
|
const resolved = nextDirectory || process.cwd()
|
||||||
if (directory === resolved) return
|
const sameDirectory = directory === resolved
|
||||||
|
clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory })
|
||||||
|
if (sameDirectory) return
|
||||||
|
|
||||||
directory = resolved
|
directory = resolved
|
||||||
attempt = 0
|
attempt = 0
|
||||||
pending.clear()
|
pending.clear()
|
||||||
lastZedSelectionKey = undefined
|
|
||||||
if (reconnect) clearTimeout(reconnect)
|
if (reconnect) clearTimeout(reconnect)
|
||||||
reconnect = undefined
|
reconnect = undefined
|
||||||
if (socket) {
|
if (socket) {
|
||||||
@@ -266,7 +286,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
current.close()
|
current.close()
|
||||||
}
|
}
|
||||||
setStore("status", "disabled")
|
setStore("status", "disabled")
|
||||||
setStore("selection", undefined)
|
|
||||||
setStore("server", undefined)
|
setStore("server", undefined)
|
||||||
connect()
|
connect()
|
||||||
}
|
}
|
||||||
@@ -293,7 +312,19 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
},
|
},
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
lastZedSelectionKey = undefined
|
lastZedSelectionKey = undefined
|
||||||
setStore("selection", undefined)
|
zedSelection = undefined
|
||||||
|
setSelection(undefined)
|
||||||
|
},
|
||||||
|
preserveSelectionFromNewSession() {
|
||||||
|
preserveSelectionOnReconnect = true
|
||||||
|
},
|
||||||
|
markSelectionSent() {
|
||||||
|
if (!store.selection) return
|
||||||
|
setStore("selectionSent", true)
|
||||||
|
},
|
||||||
|
labelState(): EditorLabelState {
|
||||||
|
if (!store.selection) return "none"
|
||||||
|
return store.selectionSent ? "sent" : "pending"
|
||||||
},
|
},
|
||||||
onMention(listener: (mention: EditorMention) => void) {
|
onMention(listener: (mention: EditorMention) => void) {
|
||||||
mentionListeners.add(listener)
|
mentionListeners.add(listener)
|
||||||
@@ -303,7 +334,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
return store.server
|
return store.server
|
||||||
},
|
},
|
||||||
reconnect(directory?: string) {
|
reconnect(directory?: string) {
|
||||||
setStore("selection", undefined)
|
|
||||||
reconnectWithDirectory(directory)
|
reconnectWithDirectory(directory)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||||
import { createEffect, createSignal } from "solid-js"
|
import { createEffect, createSignal, onMount } from "solid-js"
|
||||||
import { Logo } from "../component/logo"
|
import { Logo } from "../component/logo"
|
||||||
import { useProject } from "../context/project"
|
import { useProject } from "../context/project"
|
||||||
import { useSync } from "../context/sync"
|
import { useSync } from "../context/sync"
|
||||||
@@ -9,6 +9,7 @@ import { useRouteData } from "@tui/context/route"
|
|||||||
import { usePromptRef } from "../context/prompt"
|
import { usePromptRef } from "../context/prompt"
|
||||||
import { useLocal } from "../context/local"
|
import { useLocal } from "../context/local"
|
||||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||||
|
import { useEditorContext } from "@tui/context/editor"
|
||||||
|
|
||||||
let once = false
|
let once = false
|
||||||
const placeholder = {
|
const placeholder = {
|
||||||
@@ -24,8 +25,13 @@ export function Home() {
|
|||||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
const editor = useEditorContext()
|
||||||
let sent = false
|
let sent = false
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
editor.clearSelection()
|
||||||
|
})
|
||||||
|
|
||||||
const bind = (r: PromptRef | undefined) => {
|
const bind = (r: PromptRef | undefined) => {
|
||||||
setRef(r)
|
setRef(r)
|
||||||
promptRef.set(r)
|
promptRef.set(r)
|
||||||
|
|||||||
@@ -59,6 +59,39 @@ function createWebSocketImpl(...sockets: FakeWebSocket[]) {
|
|||||||
} as unknown as typeof WebSocket
|
} as unknown as typeof WebSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendSelection(socket: FakeWebSocket, filePath: string, text = "foo") {
|
||||||
|
socket.message(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "selection_changed",
|
||||||
|
params: {
|
||||||
|
text,
|
||||||
|
filePath,
|
||||||
|
selection: {
|
||||||
|
start: { line: 1, character: 1 },
|
||||||
|
end: { line: 1, character: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedSelection(filePath: string, text = "foo") {
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
source: "websocket" as const,
|
||||||
|
ranges: [
|
||||||
|
{
|
||||||
|
text,
|
||||||
|
selection: {
|
||||||
|
start: { line: 1, character: 1 },
|
||||||
|
end: { line: 1, character: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("useEditorContext reconnect switches editor server by session directory", async () => {
|
test("useEditorContext reconnect switches editor server by session directory", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const startupDirectory = path.join(tmp.path, "startup")
|
const startupDirectory = path.join(tmp.path, "startup")
|
||||||
@@ -93,12 +126,18 @@ test("useEditorContext reconnect switches editor server by session directory", a
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(firstSocket.closed).toBeFalse()
|
expect(firstSocket.closed).toBeFalse()
|
||||||
|
sendSelection(firstSocket, path.join(startupDirectory, "file.ts"))
|
||||||
|
|
||||||
|
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
|
||||||
|
expect(mounted.editor.labelState()).toBe("pending")
|
||||||
|
|
||||||
mounted.editor.reconnect(sessionDirectory)
|
mounted.editor.reconnect(sessionDirectory)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(firstSocket.closed).toBeTrue()
|
expect(firstSocket.closed).toBeTrue()
|
||||||
expect(secondSocket.closed).toBeFalse()
|
expect(secondSocket.closed).toBeFalse()
|
||||||
|
expect(mounted.editor.selection()).toBeUndefined()
|
||||||
|
expect(mounted.editor.labelState()).toBe("none")
|
||||||
|
|
||||||
mounted.dispose()
|
mounted.dispose()
|
||||||
})
|
})
|
||||||
@@ -131,7 +170,7 @@ test("useEditorContext favors configured port over lock files", async () => {
|
|||||||
mounted.dispose()
|
mounted.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("useEditorContext resets selection when reconnecting", async () => {
|
test("useEditorContext clears selection when reconnecting", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const startupDirectory = path.join(tmp.path, "startup")
|
const startupDirectory = path.join(tmp.path, "startup")
|
||||||
const ideDirectory = path.join(tmp.path, ".claude", "ide")
|
const ideDirectory = path.join(tmp.path, ".claude", "ide")
|
||||||
@@ -169,45 +208,66 @@ test("useEditorContext resets selection when reconnecting", async () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
socket.message(
|
sendSelection(socket, path.join(startupDirectory, "file.ts"))
|
||||||
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.connected()).toBeTrue()
|
||||||
expect(mounted.editor.server()).toEqual({
|
expect(mounted.editor.server()).toEqual({
|
||||||
protocolVersion: "2025-11-25",
|
protocolVersion: "2025-11-25",
|
||||||
serverInfo: { name: "test", version: "0.0.0" },
|
serverInfo: { name: "test", version: "0.0.0" },
|
||||||
})
|
})
|
||||||
expect(mounted.editor.selection()).toEqual({
|
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
|
||||||
filePath: path.join(startupDirectory, "file.ts"),
|
expect(mounted.editor.labelState()).toBe("pending")
|
||||||
source: "websocket",
|
mounted.editor.markSelectionSent()
|
||||||
ranges: [
|
expect(mounted.editor.labelState()).toBe("sent")
|
||||||
{
|
|
||||||
text: "foo",
|
|
||||||
selection: {
|
|
||||||
start: { line: 1, character: 1 },
|
|
||||||
end: { line: 1, character: 4 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
mounted.editor.reconnect(startupDirectory)
|
mounted.editor.reconnect(startupDirectory)
|
||||||
|
|
||||||
expect(socket.closed).toBeFalse()
|
expect(socket.closed).toBeFalse()
|
||||||
expect(mounted.editor.connected()).toBeTrue()
|
expect(mounted.editor.connected()).toBeTrue()
|
||||||
expect(mounted.editor.selection()).toBeUndefined()
|
expect(mounted.editor.selection()).toBeUndefined()
|
||||||
|
expect(mounted.editor.labelState()).toBe("none")
|
||||||
|
|
||||||
|
mounted.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("useEditorContext preserves selection for the next reconnect when requested", 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()
|
||||||
|
|
||||||
|
sendSelection(socket, path.join(startupDirectory, "file.ts"))
|
||||||
|
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
|
||||||
|
|
||||||
|
mounted.editor.markSelectionSent()
|
||||||
|
mounted.editor.preserveSelectionFromNewSession()
|
||||||
|
mounted.editor.reconnect(startupDirectory)
|
||||||
|
|
||||||
|
expect(socket.closed).toBeFalse()
|
||||||
|
expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
|
||||||
|
expect(mounted.editor.labelState()).toBe("sent")
|
||||||
|
|
||||||
|
mounted.editor.reconnect(startupDirectory)
|
||||||
|
|
||||||
|
expect(mounted.editor.selection()).toBeUndefined()
|
||||||
|
expect(mounted.editor.labelState()).toBe("none")
|
||||||
|
|
||||||
mounted.dispose()
|
mounted.dispose()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user