소스 검색

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

James Long 3 달 전
부모
커밋
2b432d9e03

+ 13 - 19
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<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
-    return subscribe((event) => {
+  function on<T extends Event["type"]>(
+    type: T,
+    handler: (event: Extract<Event, { type: T }>, metadata: EventMetadata) => void,
+  ) {
+    return subscribe((event: Event, metadata: EventMetadata) => {
       if (event.type !== type) return
-      handler(event as Extract<Event, { type: T }>)
+      handler(event as Extract<Event, { type: T }>, metadata)
     })
   }
 

+ 4 - 2
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
         }
       }

+ 28 - 5
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<Response> | undefined
 
 export function createFetch(override?: FetchHandler) {
@@ -77,11 +97,13 @@ export function createFetch(override?: FetchHandler) {
   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) {
   const calls = createFetch(override)
+  const events = createEventSource()
   let sync!: ReturnType<typeof useSync>
+  let project!: ReturnType<typeof useProject>
   let kv!: ReturnType<typeof useKV>
   let done!: () => void
   const ready = new Promise<void>((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) {
     <ArgsProvider>
       <ExitProvider>
         <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>
               <SyncProvider>
                 <Probe />
@@ -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 }
 }

+ 41 - 1
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
+    }
+  })
 })

+ 36 - 27
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<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 done!: () => void
   const ready = new Promise<void>((resolve) => {
@@ -72,30 +82,42 @@ async function mount() {
   })
 
   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>
         <Probe
-          onReady={(ctx) => {
+          onReady={async (ctx) => {
             project = ctx.project
+            await project.sync()
             done()
           }}
           seen={seen}
+          workspaces={workspaces}
         />
       </ProjectProvider>
     </SDKProvider>
   ))
 
   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 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<type
 }
 
 describe("useEvent", () => {
-  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()