fix(tui): scope events by project (#26936)

This commit is contained in:
James Long
2026-05-11 23:42:04 -04:00
committed by GitHub
parent 591eb667d5
commit 2b432d9e03
5 changed files with 122 additions and 54 deletions
@@ -2,39 +2,33 @@ import type { Event } from "@opencode-ai/sdk/v2"
import { useProject } from "./project" import { useProject } from "./project"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
type EventMetadata = {
workspace: string | undefined
}
export function useEvent() { export function useEvent() {
const project = useProject() const project = useProject()
const sdk = useSDK() const sdk = useSDK()
function subscribe(handler: (event: Event) => void) { function subscribe(handler: (event: Event, metadata: EventMetadata) => void) {
return sdk.event.on("event", (event) => { return sdk.event.on("event", (event) => {
if (event.payload.type === "sync") { if (event.payload.type === "sync") {
return return
} }
// Special hack for truly global events if (event.directory === "global" || event.project === project.project()) {
if (event.directory === "global") { handler(event.payload, { workspace: event.workspace })
handler(event.payload)
}
if (project.workspace.current()) {
if (event.workspace === project.workspace.current()) {
handler(event.payload)
}
return
}
if (event.directory === project.instance.directory()) {
handler(event.payload)
} }
}) })
} }
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) { function on<T extends Event["type"]>(
return subscribe((event) => { type: T,
handler: (event: Extract<Event, { type: T }>, metadata: EventMetadata) => void,
) {
return subscribe((event: Event, metadata: EventMetadata) => {
if (event.type !== type) return if (event.type !== type) return
handler(event as Extract<Event, { type: T }>) handler(event as Extract<Event, { type: T }>, metadata)
}) })
} }
@@ -131,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
} }
event.subscribe((event) => { event.subscribe((event, { workspace }) => {
switch (event.type) { switch (event.type) {
case "server.instance.disposed": case "server.instance.disposed":
void bootstrap() void bootstrap()
@@ -364,7 +364,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
} }
case "vcs.branch.updated": { case "vcs.branch.updated": {
setStore("vcs", { branch: event.properties.branch }) if (workspace === project.workspace.current()) {
setStore("vcs", { branch: event.properties.branch })
}
break break
} }
} }
@@ -4,9 +4,10 @@ import { onMount } from "solid-js"
import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args"
import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv"
import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" import { ProjectProvider, useProject } from "../../../../src/cli/cmd/tui/context/project"
import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk"
import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
export const worktree = "/tmp/opencode" export const worktree = "/tmp/opencode"
export const directory = `${worktree}/packages/opencode` export const directory = `${worktree}/packages/opencode`
@@ -30,6 +31,25 @@ export function eventSource(): EventSource {
return { subscribe: async () => () => {} } return { subscribe: async () => () => {} }
} }
export function createEventSource() {
let fn: ((event: GlobalEvent) => void) | undefined
return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
fn = handler
return () => {
if (fn === handler) fn = undefined
}
},
} satisfies EventSource,
emit(event: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(event)
},
}
}
type FetchHandler = (url: URL) => Response | Promise<Response> | undefined type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
export function createFetch(override?: FetchHandler) { export function createFetch(override?: FetchHandler) {
@@ -77,11 +97,13 @@ export function createFetch(override?: FetchHandler) {
return { fetch, session } return { fetch, session }
} }
type Ctx = { kv: ReturnType<typeof useKV>; sync: ReturnType<typeof useSync> } type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
export async function mount(override?: FetchHandler) { export async function mount(override?: FetchHandler) {
const calls = createFetch(override) const calls = createFetch(override)
const events = createEventSource()
let sync!: ReturnType<typeof useSync> let sync!: ReturnType<typeof useSync>
let project!: ReturnType<typeof useProject>
let kv!: ReturnType<typeof useKV> let kv!: ReturnType<typeof useKV>
let done!: () => void let done!: () => void
const ready = new Promise<void>((resolve) => { const ready = new Promise<void>((resolve) => {
@@ -89,9 +111,10 @@ export async function mount(override?: FetchHandler) {
}) })
function Probe() { function Probe() {
const ctx: Ctx = { kv: useKV(), sync: useSync() } const ctx: Ctx = { kv: useKV(), project: useProject(), sync: useSync() }
onMount(() => { onMount(() => {
sync = ctx.sync sync = ctx.sync
project = ctx.project
kv = ctx.kv kv = ctx.kv
done() done()
}) })
@@ -102,7 +125,7 @@ export async function mount(override?: FetchHandler) {
<ArgsProvider> <ArgsProvider>
<ExitProvider> <ExitProvider>
<KVProvider> <KVProvider>
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={eventSource()}> <SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
<ProjectProvider> <ProjectProvider>
<SyncProvider> <SyncProvider>
<Probe /> <Probe />
@@ -116,5 +139,5 @@ export async function mount(override?: FetchHandler) {
await ready await ready
await wait(() => sync.status === "complete") await wait(() => sync.status === "complete")
return { app, kv, sync, session: calls.session } return { app, emit: events.emit, kv, project, sync, session: calls.session }
} }
@@ -2,7 +2,21 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { tmpdir } from "../../../fixture/fixture" import { tmpdir } from "../../../fixture/fixture"
import { mount } from "./sync-fixture" import { mount, wait } from "./sync-fixture"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
function branchEvent(branch: string, workspace?: string): GlobalEvent {
return {
directory: "/tmp/other",
project: "proj_test",
workspace,
payload: {
id: `evt_vcs_${branch}`,
type: "vcs.branch.updated",
properties: { branch },
},
}
}
describe("tui sync", () => { describe("tui sync", () => {
test("refresh scopes sessions by default and lists project sessions when disabled", async () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
@@ -27,4 +41,30 @@ describe("tui sync", () => {
Global.Path.state = previous Global.Path.state = previous
} }
}) })
test("vcs branch updates only apply for the active workspace", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
const { app, emit, project, sync } = await mount()
try {
expect(sync.data.vcs?.branch).toBe("main")
project.workspace.set("ws_a")
emit(branchEvent("other", "ws_b"))
await Bun.sleep(30)
expect(sync.data.vcs?.branch).toBe("main")
emit(branchEvent("feature", "ws_a"))
await wait(() => sync.data.vcs?.branch === "feature")
expect(sync.data.vcs?.branch).toBe("feature")
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
}) })
@@ -7,6 +7,8 @@ import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/pr
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { useEvent } from "../../../src/cli/cmd/tui/context/event" import { useEvent } from "../../../src/cli/cmd/tui/context/event"
const projectID = "proj_test"
async function wait(fn: () => boolean, timeout = 2000) { async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now() const start = Date.now()
while (!fn()) { while (!fn()) {
@@ -15,9 +17,10 @@ async function wait(fn: () => boolean, timeout = 2000) {
} }
} }
function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent { function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent {
return { return {
directory: input.directory, directory: input.directory,
project: input.project,
workspace: input.workspace, workspace: input.workspace,
payload, payload,
} }
@@ -65,6 +68,13 @@ function createSource() {
async function mount() { async function mount() {
const source = createSource() const source = createSource()
const seen: Event[] = [] const seen: Event[] = []
const workspaces: Array<string | undefined> = []
const fetch = (async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input))
if (url.pathname === "/path") return Response.json({ home: "", state: "", config: "", directory: "/tmp/root" })
if (url.pathname === "/project/current") return Response.json({ id: projectID })
throw new Error(`unexpected request: ${url.pathname}`)
}) as typeof globalThis.fetch
let project!: ReturnType<typeof useProject> let project!: ReturnType<typeof useProject>
let done!: () => void let done!: () => void
const ready = new Promise<void>((resolve) => { const ready = new Promise<void>((resolve) => {
@@ -72,30 +82,42 @@ async function mount() {
}) })
const app = await testRender(() => ( const app = await testRender(() => (
<SDKProvider url="http://test" directory="/tmp/root" events={source.source}> <SDKProvider
url="http://test"
directory="/tmp/root"
events={source.source}
fetch={fetch}
>
<ProjectProvider> <ProjectProvider>
<Probe <Probe
onReady={(ctx) => { onReady={async (ctx) => {
project = ctx.project project = ctx.project
await project.sync()
done() done()
}} }}
seen={seen} seen={seen}
workspaces={workspaces}
/> />
</ProjectProvider> </ProjectProvider>
</SDKProvider> </SDKProvider>
)) ))
await ready await ready
return { app, emit: source.emit, project, seen } return { app, emit: source.emit, project, seen, workspaces }
} }
function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<typeof useProject> }) => void }) { function Probe(props: {
seen: Event[]
workspaces: Array<string | undefined>
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
}) {
const project = useProject() const project = useProject()
const event = useEvent() const event = useEvent()
onMount(() => { onMount(() => {
event.subscribe((evt) => { event.subscribe((evt, { workspace }) => {
props.seen.push(evt) props.seen.push(evt)
props.workspaces.push(workspace)
}) })
props.onReady({ project }) props.onReady({ project })
}) })
@@ -104,25 +126,26 @@ function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<type
} }
describe("useEvent", () => { describe("useEvent", () => {
test("delivers matching directory events without an active workspace", async () => { test("delivers events for the current project", async () => {
const { app, emit, seen } = await mount() const { app, emit, seen, workspaces } = await mount()
try { try {
emit(event(vcs("main"), { directory: "/tmp/root" })) emit(event(vcs("main"), { directory: "/tmp/other", project: projectID, workspace: "ws_a" }))
await wait(() => seen.length === 1) await wait(() => seen.length === 1)
expect(seen).toEqual([vcs("main")]) expect(seen).toEqual([vcs("main")])
expect(workspaces).toEqual(["ws_a"])
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
}) })
test("ignores non-matching directory events without an active workspace", async () => { test("ignores events for other projects", async () => {
const { app, emit, seen } = await mount() const { app, emit, seen } = await mount()
try { try {
emit(event(vcs("other"), { directory: "/tmp/other" })) emit(event(vcs("other"), { directory: "/tmp/root", project: "proj_other" }))
await Bun.sleep(30) await Bun.sleep(30)
expect(seen).toHaveLength(0) expect(seen).toHaveLength(0)
@@ -131,12 +154,12 @@ describe("useEvent", () => {
} }
}) })
test("delivers matching workspace events when a workspace is active", async () => { test("delivers current project events regardless of active workspace", async () => {
const { app, emit, project, seen } = await mount() const { app, emit, project, seen } = await mount()
try { try {
project.workspace.set("ws_a") project.workspace.set("ws_a")
emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" })) emit(event(vcs("ws"), { directory: "/tmp/other", project: projectID, workspace: "ws_b" }))
await wait(() => seen.length === 1) await wait(() => seen.length === 1)
@@ -146,20 +169,6 @@ describe("useEvent", () => {
} }
}) })
test("ignores non-matching workspace events when a workspace is active", async () => {
const { app, emit, project, seen } = await mount()
try {
project.workspace.set("ws_a")
emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" }))
await Bun.sleep(30)
expect(seen).toHaveLength(0)
} finally {
app.renderer.destroy()
}
})
test("delivers truly global events even when a workspace is active", async () => { test("delivers truly global events even when a workspace is active", async () => {
const { app, emit, project, seen } = await mount() const { app, emit, project, seen } = await mount()