Просмотр исходного кода

fix(core): better state handling of editor context (#25911)

James Long 4 месяцев назад
Родитель
Сommit
8e182c7782

+ 8 - 15
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -173,8 +173,7 @@ export function Prompt(props: PromptProps) {
     if (!file) return
     return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
   })
-  const [editorContextHover, setEditorContextHover] = createSignal(false)
-  let lastSubmittedEditorSelectionKey: string | undefined
+  const editorContextLabelState = createMemo(() => editor.labelState())
   const [auto, setAuto] = createSignal<AutocompleteRef>()
   const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
   const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
@@ -916,9 +915,8 @@ export function Prompt(props: PromptProps) {
     // Capture mode before it gets reset
     const currentMode = store.mode
     const editorSelection = editorContext()
-    const currentEditorSelectionKey = editorSelectionKey(editorSelection)
     const editorParts =
-      editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey
+      editorSelection && editor.labelState() === "pending"
         ? [
             {
               id: PartID.ascending(),
@@ -996,7 +994,7 @@ export function Prompt(props: PromptProps) {
           ],
         })
         .catch(() => {})
-      lastSubmittedEditorSelectionKey = currentEditorSelectionKey
+      if (editorParts.length > 0) editor.markSelectionSent()
     }
     history.append({
       ...store.prompt,
@@ -1011,13 +1009,15 @@ export function Prompt(props: PromptProps) {
     props.onSubmit?.()
 
     // temporary hack to make sure the message is sent
-    if (!props.sessionID)
+    if (!props.sessionID) {
+      if (editorParts.length > 0) editor.preserveSelectionFromNewSession()
       setTimeout(() => {
         route.navigate({
           type: "session",
           sessionID,
         })
       }, 50)
+    }
     input.clear()
     return true
   }
@@ -1608,16 +1608,9 @@ export function Prompt(props: PromptProps) {
           </Switch>
           <Show when={status().type !== "retry"}>
             <box gap={2} flexDirection="row">
-              <Show when={editorFileLabelDisplay()}>
+              <Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
                 {(file) => (
-                  <text
-                    fg={theme.secondary}
-                    onMouseOver={() => setEditorContextHover(true)}
-                    onMouseOut={() => setEditorContextHover(false)}
-                    onMouseUp={dismissEditorContext}
-                  >
-                    {editorContextHover() ? `x ${file()}` : file()}
-                  </text>
+                  <text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
                 )}
               </Show>
               <Switch>

+ 37 - 7
packages/opencode/src/cli/cmd/tui/context/editor.ts

@@ -87,6 +87,7 @@ const EditorServerInfoSchema = z.object({
 type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
 export type EditorSelection = z.infer<typeof EditorSelectionSchema>
 export type EditorMention = z.infer<typeof EditorMentionSchema>
+export type EditorLabelState = "pending" | "sent" | "none"
 type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
 
 type EditorConnection = {
@@ -111,10 +112,12 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
     const [store, setStore] = createStore<{
       status: "disabled" | "connecting" | "connected"
       selection: EditorSelection | undefined
+      selectionSent: boolean
       server: EditorServerInfo | undefined
     }>({
       status: "disabled",
       selection: undefined,
+      selectionSent: false,
       server: undefined,
     })
 
@@ -126,8 +129,24 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
     let zedSelection: Promise<void> | undefined
     let lastZedSelectionKey: string | undefined
     let directory = process.cwd()
+    let preserveSelectionOnReconnect = false
     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) => {
       if (!socket || socket.readyState !== 1) return
       socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
@@ -158,7 +177,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
             const key = editorSelectionKey(selection)
             if (key !== lastZedSelectionKey) {
               lastZedSelectionKey = key
-              setStore("selection", selection)
+              setSelection(selection)
               setStore("status", selection ? "connected" : "disabled")
             }
           })
@@ -198,7 +217,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
         const selection =
           message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
         if (selection?.success) {
-          setStore("selection", { ...selection.data, source: "websocket" })
+          setSelection({ ...selection.data, source: "websocket" })
           return
         }
 
@@ -252,12 +271,13 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
 
     const reconnectWithDirectory = (nextDirectory?: string) => {
       const resolved = nextDirectory || process.cwd()
-      if (directory === resolved) return
+      const sameDirectory = directory === resolved
+      clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory })
+      if (sameDirectory) return
 
       directory = resolved
       attempt = 0
       pending.clear()
-      lastZedSelectionKey = undefined
       if (reconnect) clearTimeout(reconnect)
       reconnect = undefined
       if (socket) {
@@ -266,7 +286,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
         current.close()
       }
       setStore("status", "disabled")
-      setStore("selection", undefined)
       setStore("server", undefined)
       connect()
     }
@@ -293,7 +312,19 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
       },
       clearSelection() {
         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) {
         mentionListeners.add(listener)
@@ -303,7 +334,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
         return store.server
       },
       reconnect(directory?: string) {
-        setStore("selection", undefined)
         reconnectWithDirectory(directory)
       },
     }

+ 7 - 1
packages/opencode/src/cli/cmd/tui/routes/home.tsx

@@ -1,5 +1,5 @@
 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 { useProject } from "../context/project"
 import { useSync } from "../context/sync"
@@ -9,6 +9,7 @@ import { useRouteData } from "@tui/context/route"
 import { usePromptRef } from "../context/prompt"
 import { useLocal } from "../context/local"
 import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
+import { useEditorContext } from "@tui/context/editor"
 
 let once = false
 const placeholder = {
@@ -24,8 +25,13 @@ export function Home() {
   const [ref, setRef] = createSignal<PromptRef | undefined>()
   const args = useArgs()
   const local = useLocal()
+  const editor = useEditorContext()
   let sent = false
 
+  onMount(() => {
+    editor.clearSelection()
+  })
+
   const bind = (r: PromptRef | undefined) => {
     setRef(r)
     promptRef.set(r)

+ 88 - 28
packages/opencode/test/cli/tui/editor-context.test.tsx

@@ -59,6 +59,39 @@ function createWebSocketImpl(...sockets: FakeWebSocket[]) {
   } 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 () => {
   await using tmp = await tmpdir()
   const startupDirectory = path.join(tmp.path, "startup")
@@ -93,12 +126,18 @@ test("useEditorContext reconnect switches editor server by session directory", a
   await nextTick()
 
   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)
   await nextTick()
 
   expect(firstSocket.closed).toBeTrue()
   expect(secondSocket.closed).toBeFalse()
+  expect(mounted.editor.selection()).toBeUndefined()
+  expect(mounted.editor.labelState()).toBe("none")
 
   mounted.dispose()
 })
@@ -131,7 +170,7 @@ test("useEditorContext favors configured port over lock files", async () => {
   mounted.dispose()
 })
 
-test("useEditorContext resets selection when reconnecting", async () => {
+test("useEditorContext clears selection when reconnecting", async () => {
   await using tmp = await tmpdir()
   const startupDirectory = path.join(tmp.path, "startup")
   const ideDirectory = path.join(tmp.path, ".claude", "ide")
@@ -169,45 +208,66 @@ test("useEditorContext resets selection when reconnecting", async () => {
       },
     }),
   )
-  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 },
-        },
-      },
-    }),
-  )
+  sendSelection(socket, path.join(startupDirectory, "file.ts"))
 
   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({
-    filePath: path.join(startupDirectory, "file.ts"),
-    source: "websocket",
-    ranges: [
-      {
-        text: "foo",
-        selection: {
-          start: { line: 1, character: 1 },
-          end: { line: 1, character: 4 },
-        },
-      },
-    ],
-  })
+  expect(mounted.editor.selection()).toEqual(expectedSelection(path.join(startupDirectory, "file.ts")))
+  expect(mounted.editor.labelState()).toBe("pending")
+  mounted.editor.markSelectionSent()
+  expect(mounted.editor.labelState()).toBe("sent")
 
   mounted.editor.reconnect(startupDirectory)
 
   expect(socket.closed).toBeFalse()
   expect(mounted.editor.connected()).toBeTrue()
   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()
 })