Skip to content

Commit 2b432d9

Browse files
authored
fix(tui): scope events by project (#26936)
1 parent 591eb66 commit 2b432d9

5 files changed

Lines changed: 122 additions & 54 deletions

File tree

packages/opencode/src/cli/cmd/tui/context/event.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,33 @@ import type { Event } from "@opencode-ai/sdk/v2"
22
import { useProject } from "./project"
33
import { useSDK } from "./sdk"
44

5+
type EventMetadata = {
6+
workspace: string | undefined
7+
}
8+
59
export function useEvent() {
610
const project = useProject()
711
const sdk = useSDK()
812

9-
function subscribe(handler: (event: Event) => void) {
13+
function subscribe(handler: (event: Event, metadata: EventMetadata) => void) {
1014
return sdk.event.on("event", (event) => {
1115
if (event.payload.type === "sync") {
1216
return
1317
}
1418

15-
// Special hack for truly global events
16-
if (event.directory === "global") {
17-
handler(event.payload)
18-
}
19-
20-
if (project.workspace.current()) {
21-
if (event.workspace === project.workspace.current()) {
22-
handler(event.payload)
23-
}
24-
25-
return
26-
}
27-
28-
if (event.directory === project.instance.directory()) {
29-
handler(event.payload)
19+
if (event.directory === "global" || event.project === project.project()) {
20+
handler(event.payload, { workspace: event.workspace })
3021
}
3122
})
3223
}
3324

34-
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
35-
return subscribe((event) => {
25+
function on<T extends Event["type"]>(
26+
type: T,
27+
handler: (event: Extract<Event, { type: T }>, metadata: EventMetadata) => void,
28+
) {
29+
return subscribe((event: Event, metadata: EventMetadata) => {
3630
if (event.type !== type) return
37-
handler(event as Extract<Event, { type: T }>)
31+
handler(event as Extract<Event, { type: T }>, metadata)
3832
})
3933
}
4034

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
131131
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
132132
}
133133

134-
event.subscribe((event) => {
134+
event.subscribe((event, { workspace }) => {
135135
switch (event.type) {
136136
case "server.instance.disposed":
137137
void bootstrap()
@@ -364,7 +364,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
364364
}
365365

366366
case "vcs.branch.updated": {
367-
setStore("vcs", { branch: event.properties.branch })
367+
if (workspace === project.workspace.current()) {
368+
setStore("vcs", { branch: event.properties.branch })
369+
}
368370
break
369371
}
370372
}

packages/opencode/test/cli/cmd/tui/sync-fixture.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { onMount } from "solid-js"
44
import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args"
55
import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
66
import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv"
7-
import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project"
7+
import { ProjectProvider, useProject } from "../../../../src/cli/cmd/tui/context/project"
88
import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk"
99
import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
10+
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
1011

1112
export const worktree = "/tmp/opencode"
1213
export const directory = `${worktree}/packages/opencode`
@@ -30,6 +31,25 @@ export function eventSource(): EventSource {
3031
return { subscribe: async () => () => {} }
3132
}
3233

34+
export function createEventSource() {
35+
let fn: ((event: GlobalEvent) => void) | undefined
36+
37+
return {
38+
source: {
39+
subscribe: async (handler: (event: GlobalEvent) => void) => {
40+
fn = handler
41+
return () => {
42+
if (fn === handler) fn = undefined
43+
}
44+
},
45+
} satisfies EventSource,
46+
emit(event: GlobalEvent) {
47+
if (!fn) throw new Error("event source not ready")
48+
fn(event)
49+
},
50+
}
51+
}
52+
3353
type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
3454

3555
export function createFetch(override?: FetchHandler) {
@@ -77,21 +97,24 @@ export function createFetch(override?: FetchHandler) {
7797
return { fetch, session }
7898
}
7999

80-
type Ctx = { kv: ReturnType<typeof useKV>; sync: ReturnType<typeof useSync> }
100+
type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
81101

82102
export async function mount(override?: FetchHandler) {
83103
const calls = createFetch(override)
104+
const events = createEventSource()
84105
let sync!: ReturnType<typeof useSync>
106+
let project!: ReturnType<typeof useProject>
85107
let kv!: ReturnType<typeof useKV>
86108
let done!: () => void
87109
const ready = new Promise<void>((resolve) => {
88110
done = resolve
89111
})
90112

91113
function Probe() {
92-
const ctx: Ctx = { kv: useKV(), sync: useSync() }
114+
const ctx: Ctx = { kv: useKV(), project: useProject(), sync: useSync() }
93115
onMount(() => {
94116
sync = ctx.sync
117+
project = ctx.project
95118
kv = ctx.kv
96119
done()
97120
})
@@ -102,7 +125,7 @@ export async function mount(override?: FetchHandler) {
102125
<ArgsProvider>
103126
<ExitProvider>
104127
<KVProvider>
105-
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={eventSource()}>
128+
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
106129
<ProjectProvider>
107130
<SyncProvider>
108131
<Probe />
@@ -116,5 +139,5 @@ export async function mount(override?: FetchHandler) {
116139

117140
await ready
118141
await wait(() => sync.status === "complete")
119-
return { app, kv, sync, session: calls.session }
142+
return { app, emit: events.emit, kv, project, sync, session: calls.session }
120143
}

packages/opencode/test/cli/cmd/tui/sync.test.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,21 @@
22
import { describe, expect, test } from "bun:test"
33
import { Global } from "@opencode-ai/core/global"
44
import { tmpdir } from "../../../fixture/fixture"
5-
import { mount } from "./sync-fixture"
5+
import { mount, wait } from "./sync-fixture"
6+
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
7+
8+
function branchEvent(branch: string, workspace?: string): GlobalEvent {
9+
return {
10+
directory: "/tmp/other",
11+
project: "proj_test",
12+
workspace,
13+
payload: {
14+
id: `evt_vcs_${branch}`,
15+
type: "vcs.branch.updated",
16+
properties: { branch },
17+
},
18+
}
19+
}
620

721
describe("tui sync", () => {
822
test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
@@ -27,4 +41,30 @@ describe("tui sync", () => {
2741
Global.Path.state = previous
2842
}
2943
})
44+
45+
test("vcs branch updates only apply for the active workspace", async () => {
46+
const previous = Global.Path.state
47+
await using tmp = await tmpdir()
48+
Global.Path.state = tmp.path
49+
await Bun.write(`${tmp.path}/kv.json`, "{}")
50+
const { app, emit, project, sync } = await mount()
51+
52+
try {
53+
expect(sync.data.vcs?.branch).toBe("main")
54+
55+
project.workspace.set("ws_a")
56+
emit(branchEvent("other", "ws_b"))
57+
await Bun.sleep(30)
58+
59+
expect(sync.data.vcs?.branch).toBe("main")
60+
61+
emit(branchEvent("feature", "ws_a"))
62+
await wait(() => sync.data.vcs?.branch === "feature")
63+
64+
expect(sync.data.vcs?.branch).toBe("feature")
65+
} finally {
66+
app.renderer.destroy()
67+
Global.Path.state = previous
68+
}
69+
})
3070
})

packages/opencode/test/cli/tui/use-event.test.tsx

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/pr
77
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
88
import { useEvent } from "../../../src/cli/cmd/tui/context/event"
99

10+
const projectID = "proj_test"
11+
1012
async function wait(fn: () => boolean, timeout = 2000) {
1113
const start = Date.now()
1214
while (!fn()) {
@@ -15,9 +17,10 @@ async function wait(fn: () => boolean, timeout = 2000) {
1517
}
1618
}
1719

18-
function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent {
20+
function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent {
1921
return {
2022
directory: input.directory,
23+
project: input.project,
2124
workspace: input.workspace,
2225
payload,
2326
}
@@ -65,37 +68,56 @@ function createSource() {
6568
async function mount() {
6669
const source = createSource()
6770
const seen: Event[] = []
71+
const workspaces: Array<string | undefined> = []
72+
const fetch = (async (input: RequestInfo | URL) => {
73+
const url = new URL(input instanceof Request ? input.url : String(input))
74+
if (url.pathname === "/path") return Response.json({ home: "", state: "", config: "", directory: "/tmp/root" })
75+
if (url.pathname === "/project/current") return Response.json({ id: projectID })
76+
throw new Error(`unexpected request: ${url.pathname}`)
77+
}) as typeof globalThis.fetch
6878
let project!: ReturnType<typeof useProject>
6979
let done!: () => void
7080
const ready = new Promise<void>((resolve) => {
7181
done = resolve
7282
})
7383

7484
const app = await testRender(() => (
75-
<SDKProvider url="http://test" directory="/tmp/root" events={source.source}>
85+
<SDKProvider
86+
url="http://test"
87+
directory="/tmp/root"
88+
events={source.source}
89+
fetch={fetch}
90+
>
7691
<ProjectProvider>
7792
<Probe
78-
onReady={(ctx) => {
93+
onReady={async (ctx) => {
7994
project = ctx.project
95+
await project.sync()
8096
done()
8197
}}
8298
seen={seen}
99+
workspaces={workspaces}
83100
/>
84101
</ProjectProvider>
85102
</SDKProvider>
86103
))
87104

88105
await ready
89-
return { app, emit: source.emit, project, seen }
106+
return { app, emit: source.emit, project, seen, workspaces }
90107
}
91108

92-
function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<typeof useProject> }) => void }) {
109+
function Probe(props: {
110+
seen: Event[]
111+
workspaces: Array<string | undefined>
112+
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
113+
}) {
93114
const project = useProject()
94115
const event = useEvent()
95116

96117
onMount(() => {
97-
event.subscribe((evt) => {
118+
event.subscribe((evt, { workspace }) => {
98119
props.seen.push(evt)
120+
props.workspaces.push(workspace)
99121
})
100122
props.onReady({ project })
101123
})
@@ -104,25 +126,26 @@ function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<type
104126
}
105127

106128
describe("useEvent", () => {
107-
test("delivers matching directory events without an active workspace", async () => {
108-
const { app, emit, seen } = await mount()
129+
test("delivers events for the current project", async () => {
130+
const { app, emit, seen, workspaces } = await mount()
109131

110132
try {
111-
emit(event(vcs("main"), { directory: "/tmp/root" }))
133+
emit(event(vcs("main"), { directory: "/tmp/other", project: projectID, workspace: "ws_a" }))
112134

113135
await wait(() => seen.length === 1)
114136

115137
expect(seen).toEqual([vcs("main")])
138+
expect(workspaces).toEqual(["ws_a"])
116139
} finally {
117140
app.renderer.destroy()
118141
}
119142
})
120143

121-
test("ignores non-matching directory events without an active workspace", async () => {
144+
test("ignores events for other projects", async () => {
122145
const { app, emit, seen } = await mount()
123146

124147
try {
125-
emit(event(vcs("other"), { directory: "/tmp/other" }))
148+
emit(event(vcs("other"), { directory: "/tmp/root", project: "proj_other" }))
126149
await Bun.sleep(30)
127150

128151
expect(seen).toHaveLength(0)
@@ -131,12 +154,12 @@ describe("useEvent", () => {
131154
}
132155
})
133156

134-
test("delivers matching workspace events when a workspace is active", async () => {
157+
test("delivers current project events regardless of active workspace", async () => {
135158
const { app, emit, project, seen } = await mount()
136159

137160
try {
138161
project.workspace.set("ws_a")
139-
emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" }))
162+
emit(event(vcs("ws"), { directory: "/tmp/other", project: projectID, workspace: "ws_b" }))
140163

141164
await wait(() => seen.length === 1)
142165

@@ -146,20 +169,6 @@ describe("useEvent", () => {
146169
}
147170
})
148171

149-
test("ignores non-matching workspace events when a workspace is active", async () => {
150-
const { app, emit, project, seen } = await mount()
151-
152-
try {
153-
project.workspace.set("ws_a")
154-
emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" }))
155-
await Bun.sleep(30)
156-
157-
expect(seen).toHaveLength(0)
158-
} finally {
159-
app.renderer.destroy()
160-
}
161-
})
162-
163172
test("delivers truly global events even when a workspace is active", async () => {
164173
const { app, emit, project, seen } = await mount()
165174

0 commit comments

Comments
 (0)