diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts index 156f9c94768a..5d814ecdcaab 100644 --- a/packages/opencode/src/cli/cmd/tui/context/event.ts +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -2,39 +2,33 @@ import type { Event } from "@opencode-ai/sdk/v2" import { useProject } from "./project" import { useSDK } from "./sdk" +type EventMetadata = { + workspace: string | undefined +} + export function useEvent() { const project = useProject() const sdk = useSDK() - function subscribe(handler: (event: Event) => void) { + function subscribe(handler: (event: Event, metadata: EventMetadata) => void) { return sdk.event.on("event", (event) => { if (event.payload.type === "sync") { return } - // Special hack for truly global events - if (event.directory === "global") { - 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) + if (event.directory === "global" || event.project === project.project()) { + handler(event.payload, { workspace: event.workspace }) } }) } - function on(type: T, handler: (event: Extract) => void) { - return subscribe((event) => { + function on( + type: T, + handler: (event: Extract, metadata: EventMetadata) => void, + ) { + return subscribe((event: Event, metadata: EventMetadata) => { if (event.type !== type) return - handler(event as Extract) + handler(event as Extract, metadata) }) } diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 0d4cb2e6e285..76b1807abd54 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -131,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) } - event.subscribe((event) => { + event.subscribe((event, { workspace }) => { switch (event.type) { case "server.instance.disposed": void bootstrap() @@ -364,7 +364,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "vcs.branch.updated": { - setStore("vcs", { branch: event.properties.branch }) + if (workspace === project.workspace.current()) { + setStore("vcs", { branch: event.properties.branch }) + } break } } diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx index d9ecdbe9d576..5f51374c16c5 100644 --- a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx @@ -4,9 +4,10 @@ import { onMount } from "solid-js" import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" 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 { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" export const worktree = "/tmp/opencode" export const directory = `${worktree}/packages/opencode` @@ -30,6 +31,25 @@ export function eventSource(): EventSource { 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 | undefined export function createFetch(override?: FetchHandler) { @@ -77,11 +97,13 @@ export function createFetch(override?: FetchHandler) { return { fetch, session } } -type Ctx = { kv: ReturnType; sync: ReturnType } +type Ctx = { kv: ReturnType; project: ReturnType; sync: ReturnType } export async function mount(override?: FetchHandler) { const calls = createFetch(override) + const events = createEventSource() let sync!: ReturnType + let project!: ReturnType let kv!: ReturnType let done!: () => void const ready = new Promise((resolve) => { @@ -89,9 +111,10 @@ export async function mount(override?: FetchHandler) { }) function Probe() { - const ctx: Ctx = { kv: useKV(), sync: useSync() } + const ctx: Ctx = { kv: useKV(), project: useProject(), sync: useSync() } onMount(() => { sync = ctx.sync + project = ctx.project kv = ctx.kv done() }) @@ -102,7 +125,7 @@ export async function mount(override?: FetchHandler) { - + @@ -116,5 +139,5 @@ export async function mount(override?: FetchHandler) { await ready await wait(() => sync.status === "complete") - return { app, kv, sync, session: calls.session } + return { app, emit: events.emit, kv, project, sync, session: calls.session } } diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index f67257f6ceb2..714c39a781be 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -2,7 +2,21 @@ import { describe, expect, test } from "bun:test" import { Global } from "@opencode-ai/core/global" 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", () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => { @@ -27,4 +41,30 @@ describe("tui sync", () => { 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 + } + }) }) diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index 78253361b76c..ac2d942db686 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -7,6 +7,8 @@ import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/pr import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" import { useEvent } from "../../../src/cli/cmd/tui/context/event" +const projectID = "proj_test" + async function wait(fn: () => boolean, timeout = 2000) { const start = Date.now() 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 { directory: input.directory, + project: input.project, workspace: input.workspace, payload, } @@ -65,6 +68,13 @@ function createSource() { async function mount() { const source = createSource() const seen: Event[] = [] + const workspaces: Array = [] + 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 let done!: () => void const ready = new Promise((resolve) => { @@ -72,30 +82,42 @@ async function mount() { }) const app = await testRender(() => ( - + { + onReady={async (ctx) => { project = ctx.project + await project.sync() done() }} seen={seen} + workspaces={workspaces} /> )) 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 }) => void }) { +function Probe(props: { + seen: Event[] + workspaces: Array + onReady: (ctx: { project: ReturnType }) => void +}) { const project = useProject() const event = useEvent() onMount(() => { - event.subscribe((evt) => { + event.subscribe((evt, { workspace }) => { props.seen.push(evt) + props.workspaces.push(workspace) }) props.onReady({ project }) }) @@ -104,25 +126,26 @@ function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType { - test("delivers matching directory events without an active workspace", async () => { - const { app, emit, seen } = await mount() + test("delivers events for the current project", async () => { + const { app, emit, seen, workspaces } = await mount() 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) expect(seen).toEqual([vcs("main")]) + expect(workspaces).toEqual(["ws_a"]) } finally { 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() try { - emit(event(vcs("other"), { directory: "/tmp/other" })) + emit(event(vcs("other"), { directory: "/tmp/root", project: "proj_other" })) await Bun.sleep(30) 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() try { 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) @@ -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 () => { const { app, emit, project, seen } = await mount()