fix(tui): scope events by project (#26936)
This commit is contained in:
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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue