From 30334ebcaa0f1d533d6fb9d0a19d86dcdbaa22af Mon Sep 17 00:00:00 2001 From: "detail-app[bot]" <180357370+detail-app[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:16:28 +0000 Subject: [PATCH] fix(tui): ignore stale Zed poll when switching session directories --- .../test/cli/tui/editor-context.test.tsx | 154 +++++++++++++++++- packages/tui/src/context/editor.ts | 4 +- 2 files changed, 154 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/cli/tui/editor-context.test.tsx b/packages/opencode/test/cli/tui/editor-context.test.tsx index 2b114b5cec1b..ac2310c40d33 100644 --- a/packages/opencode/test/cli/tui/editor-context.test.tsx +++ b/packages/opencode/test/cli/tui/editor-context.test.tsx @@ -11,17 +11,31 @@ import { discoverEditorConnection } from "@opencode-ai/tui/editor" const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT +const originalZedTerm = process.env.ZED_TERM +const originalTermProgram = process.env.TERM_PROGRAM afterEach(() => { process.env.CLAUDE_CODE_SSE_PORT = originalClaudePort process.env.OPENCODE_EDITOR_SSE_PORT = originalOpencodePort + if (originalZedTerm === undefined) delete process.env.ZED_TERM + else process.env.ZED_TERM = originalZedTerm + if (originalTermProgram === undefined) delete process.env.TERM_PROGRAM + else process.env.TERM_PROGRAM = originalTermProgram }) function nextTick() { return new Promise((resolve) => queueMicrotask(resolve)) } -function mountEditorContext(WebSocketImpl?: typeof WebSocket) { +function flush() { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function mountEditorContext( + WebSocketImpl?: typeof WebSocket, + integration: EditorIntegration = editorService, + cwd = process.cwd(), +) { let editor!: ReturnType let dispose!: () => void @@ -35,8 +49,8 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) { const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT return ( - - + + @@ -295,3 +309,137 @@ test("useEditorContext connects with OPENCODE_EDITOR_SSE_PORT", async () => { mounted.dispose() }) + +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolveFn) => { + resolve = resolveFn + }) + return { promise, resolve } +} + +function zedSelectionResult(filePath: string, text = "old") { + return { + type: "selection" as const, + selection: { + filePath, + source: "zed" as const, + ranges: [ + { + text, + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }, + ], + }, + } +} + +function expectedZedSelection(filePath: string, text = "old") { + return { + filePath, + source: "zed" as const, + ranges: [ + { + text, + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }, + ], + } +} + +function createZedIntegration(selection: (directory: string) => Promise): EditorIntegration { + return { + connection: () => undefined, + selection, + } +} + +function zedEnv() { + process.env.ZED_TERM = "true" + process.env.CLAUDE_CODE_SSE_PORT = undefined + process.env.OPENCODE_EDITOR_SSE_PORT = undefined +} + +test("useEditorContext applies Zed selection from poll in Zed terminal mode", async () => { + await using tmp = await tmpdir() + const directory = path.join(tmp.path, "project") + await mkdir(directory, { recursive: true }) + zedEnv() + + const filePath = path.join(directory, "file.ts") + const integration = createZedIntegration(() => Promise.resolve(zedSelectionResult(filePath))) + + const mounted = mountEditorContext(undefined, integration, directory) + await flush() + + expect(mounted.editor.enabled()).toBeTrue() + expect(mounted.editor.selection()).toEqual(expectedZedSelection(filePath)) + expect(mounted.editor.labelState()).toBe("pending") + + mounted.dispose() +}) + +test("useEditorContext ignores stale Zed poll from previous directory after reconnect", async () => { + await using tmp = await tmpdir() + const directoryA = path.join(tmp.path, "A") + const directoryB = path.join(tmp.path, "B") + await mkdir(directoryA, { recursive: true }) + await mkdir(directoryB, { recursive: true }) + zedEnv() + + const pollA = createDeferred() + const pollB = createDeferred() + const integration = createZedIntegration((directory) => (directory === directoryA ? pollA.promise : pollB.promise)) + + const mounted = mountEditorContext(undefined, integration, directoryA) + await flush() + + mounted.editor.reconnect(directoryB) + await flush() + + pollA.resolve(zedSelectionResult(path.join(directoryA, "file.ts"), "stale")) + await flush() + + expect(mounted.editor.selection()).toBeUndefined() + expect(mounted.editor.labelState()).toBe("none") + expect(mounted.editor.connected()).toBeFalse() + + mounted.dispose() +}) + +test("useEditorContext applies new-directory Zed poll after ignoring stale poll", async () => { + await using tmp = await tmpdir() + const directoryA = path.join(tmp.path, "A") + const directoryB = path.join(tmp.path, "B") + await mkdir(directoryA, { recursive: true }) + await mkdir(directoryB, { recursive: true }) + zedEnv() + + const pollA = createDeferred() + const pollB = createDeferred() + const integration = createZedIntegration((directory) => (directory === directoryA ? pollA.promise : pollB.promise)) + + const mounted = mountEditorContext(undefined, integration, directoryA) + await flush() + + mounted.editor.reconnect(directoryB) + await flush() + + pollA.resolve(zedSelectionResult(path.join(directoryA, "file.ts"), "stale")) + await flush() + + pollB.resolve(zedSelectionResult(path.join(directoryB, "file.ts"), "fresh")) + await flush() + + expect(mounted.editor.selection()).toEqual(expectedZedSelection(path.join(directoryB, "file.ts"), "fresh")) + expect(mounted.editor.labelState()).toBe("pending") + expect(mounted.editor.connected()).toBeTrue() + + mounted.dispose() +}) diff --git a/packages/tui/src/context/editor.ts b/packages/tui/src/context/editor.ts index cf2fbbf9e62b..061116af4ff5 100644 --- a/packages/tui/src/context/editor.ts +++ b/packages/tui/src/context/editor.ts @@ -186,10 +186,11 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create return } + const pollDirectory = directory zedSelection ??= editor .selection(directory) .then((result) => { - if (closed || socket) return + if (closed || socket || directory !== pollDirectory) return if (!isRecord(result) || result.type === "unavailable") return const decoded = result.type === "selection" ? decodeEditorSelection(result.selection) : Option.none() const selection = Option.getOrUndefined(decoded) @@ -305,6 +306,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create } setStore("status", "disabled") setStore("server", undefined) + zedSelection = undefined connect() }