Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 151 additions & 3 deletions packages/opencode/test/cli/tui/editor-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => queueMicrotask(resolve))
}

function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
function flush() {
return new Promise<void>((resolve) => setTimeout(resolve, 0))
}

function mountEditorContext(
WebSocketImpl?: typeof WebSocket,
integration: EditorIntegration = editorService,
cwd = process.cwd(),
) {
let editor!: ReturnType<typeof useEditorContext>
let dispose!: () => void

Expand All @@ -35,8 +49,8 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {

const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
return (
<TestTuiContexts cwd={process.cwd()} paths={{ home: os.homedir() }}>
<EditorContextProvider integration={editorService} WebSocketImpl={WebSocketImpl}>
<TestTuiContexts cwd={cwd} paths={{ home: os.homedir() }}>
<EditorContextProvider integration={integration} WebSocketImpl={WebSocketImpl}>
<Consumer />
</EditorContextProvider>
</TestTuiContexts>
Expand Down Expand Up @@ -295,3 +309,137 @@ test("useEditorContext connects with OPENCODE_EDITOR_SSE_PORT", async () => {

mounted.dispose()
})

function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<unknown>): 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<unknown>()
const pollB = createDeferred<unknown>()
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<unknown>()
const pollB = createDeferred<unknown>()
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()
})
4 changes: 3 additions & 1 deletion packages/tui/src/context/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -305,6 +306,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
}
setStore("status", "disabled")
setStore("server", undefined)
zedSelection = undefined
connect()
}

Expand Down