Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 13 additions & 19 deletions packages/opencode/src/cli/cmd/tui/context/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}

Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
}
Expand Down
33 changes: 28 additions & 5 deletions packages/opencode/test/cli/cmd/tui/sync-fixture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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) {
Expand Down Expand Up @@ -77,21 +97,24 @@ 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) => {
done = resolve
})

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()
})
Expand All @@ -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 />
Expand All @@ -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 }
}
42 changes: 41 additions & 1 deletion packages/opencode/test/cli/cmd/tui/sync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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
}
})
})
63 changes: 36 additions & 27 deletions packages/opencode/test/cli/tui/use-event.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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,
}
Expand Down Expand Up @@ -65,37 +68,56 @@ 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) => {
done = resolve
})

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 })
})
Expand All @@ -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)
Expand All @@ -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)

Expand All @@ -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()

Expand Down
Loading