refactor(app): use one server event model (#43168)
This commit is contained in:
@@ -225,8 +225,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
},
|
||||
)
|
||||
|
||||
const stop = sdk().event.listen((e) => {
|
||||
invalidateFromWatcher(e.details, {
|
||||
const stop = sdk().event.on("filesystem.changed", (event) => {
|
||||
invalidateFromWatcher(event, {
|
||||
normalize: path.normalize,
|
||||
hasFile: (file) => Boolean(store.file[file]),
|
||||
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { invalidateFromWatcher } from "./watcher"
|
||||
|
||||
type FilesystemEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
|
||||
|
||||
const filesystemEvent = (file: string, event: FilesystemEvent["data"]["event"]): FilesystemEvent => ({
|
||||
id: `evt_${file}`,
|
||||
created: 1,
|
||||
type: "filesystem.changed",
|
||||
data: { file, event },
|
||||
})
|
||||
|
||||
describe("file watcher invalidation", () => {
|
||||
test("reloads open files and refreshes loaded parent on add", () => {
|
||||
const loads: string[] = []
|
||||
const refresh: string[] = []
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/new.ts",
|
||||
event: "add",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: (path) => path === "src/new.ts",
|
||||
loadFile: (path) => loads.push(path),
|
||||
node: () => undefined,
|
||||
isDirLoaded: (path) => path === "src",
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
invalidateFromWatcher(filesystemEvent("src/new.ts", "add"), {
|
||||
normalize: (input) => input,
|
||||
hasFile: (path) => path === "src/new.ts",
|
||||
loadFile: (path) => loads.push(path),
|
||||
node: () => undefined,
|
||||
isDirLoaded: (path) => path === "src",
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
})
|
||||
|
||||
expect(loads).toEqual(["src/new.ts"])
|
||||
expect(refresh).toEqual(["src"])
|
||||
@@ -30,30 +31,21 @@ describe("file watcher invalidation", () => {
|
||||
test("reloads files that are open in tabs", () => {
|
||||
const loads: string[] = []
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/open.ts",
|
||||
event: "change",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
isOpen: (path) => path === "src/open.ts",
|
||||
loadFile: (path) => loads.push(path),
|
||||
node: () => ({
|
||||
path: "src/open.ts",
|
||||
type: "file",
|
||||
name: "open.ts",
|
||||
absolute: "/repo/src/open.ts",
|
||||
ignored: false,
|
||||
}),
|
||||
isDirLoaded: () => false,
|
||||
refreshDir: () => {},
|
||||
},
|
||||
)
|
||||
invalidateFromWatcher(filesystemEvent("src/open.ts", "change"), {
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
isOpen: (path) => path === "src/open.ts",
|
||||
loadFile: (path) => loads.push(path),
|
||||
node: () => ({
|
||||
path: "src/open.ts",
|
||||
type: "file",
|
||||
name: "open.ts",
|
||||
absolute: "/repo/src/open.ts",
|
||||
ignored: false,
|
||||
}),
|
||||
isDirLoaded: () => false,
|
||||
refreshDir: () => {},
|
||||
})
|
||||
|
||||
expect(loads).toEqual(["src/open.ts"])
|
||||
})
|
||||
@@ -61,47 +53,29 @@ describe("file watcher invalidation", () => {
|
||||
test("refreshes only changed loaded directory nodes", () => {
|
||||
const refresh: string[] = []
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src",
|
||||
event: "change",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
|
||||
isDirLoaded: (path) => path === "src",
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
invalidateFromWatcher(filesystemEvent("src", "change"), {
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
|
||||
isDirLoaded: (path) => path === "src",
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
})
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/file.ts",
|
||||
event: "change",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => ({
|
||||
path: "src/file.ts",
|
||||
type: "file",
|
||||
name: "file.ts",
|
||||
absolute: "/repo/src/file.ts",
|
||||
ignored: false,
|
||||
}),
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
invalidateFromWatcher(filesystemEvent("src/file.ts", "change"), {
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => ({
|
||||
path: "src/file.ts",
|
||||
type: "file",
|
||||
name: "file.ts",
|
||||
absolute: "/repo/src/file.ts",
|
||||
ignored: false,
|
||||
}),
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
})
|
||||
|
||||
expect(refresh).toEqual(["src"])
|
||||
})
|
||||
@@ -109,40 +83,16 @@ describe("file watcher invalidation", () => {
|
||||
test("ignores invalid or git watcher updates", () => {
|
||||
const refresh: string[] = []
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: ".git/index.lock",
|
||||
event: "change",
|
||||
},
|
||||
invalidateFromWatcher(filesystemEvent(".git/index.lock", "change"), {
|
||||
normalize: (input) => input,
|
||||
hasFile: () => true,
|
||||
loadFile: () => {
|
||||
throw new Error("should not load")
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => true,
|
||||
loadFile: () => {
|
||||
throw new Error("should not load")
|
||||
},
|
||||
node: () => undefined,
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "project.updated",
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => undefined,
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
node: () => undefined,
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
})
|
||||
|
||||
expect(refresh).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { FileNode } from "@/types"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
|
||||
type WatcherEvent = {
|
||||
type: string
|
||||
properties: unknown
|
||||
}
|
||||
type WatcherEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
|
||||
|
||||
type WatcherOps = {
|
||||
normalize: (input: string) => string
|
||||
@@ -16,15 +14,7 @@ type WatcherOps = {
|
||||
}
|
||||
|
||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (event.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||
const kind = typeof props?.event === "string" ? props.event : undefined
|
||||
if (!rawPath) return
|
||||
if (!kind) return
|
||||
|
||||
const path = ops.normalize(rawPath)
|
||||
const path = ops.normalize(event.data.file)
|
||||
if (!path) return
|
||||
if (path.startsWith(".git/")) return
|
||||
|
||||
@@ -32,20 +22,12 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
ops.loadFile(path)
|
||||
}
|
||||
|
||||
if (kind === "change") {
|
||||
const dir = (() => {
|
||||
if (path === "") return ""
|
||||
const node = ops.node(path)
|
||||
if (node?.type !== "directory") return
|
||||
return path
|
||||
})()
|
||||
if (dir === undefined) return
|
||||
if (!ops.isDirLoaded(dir)) return
|
||||
ops.refreshDir(dir)
|
||||
if (event.data.event === "change") {
|
||||
if (ops.node(path)?.type !== "directory") return
|
||||
if (!ops.isDirLoaded(path)) return
|
||||
ops.refreshDir(path)
|
||||
return
|
||||
}
|
||||
if (kind !== "add" && kind !== "unlink") return
|
||||
|
||||
const parent = path.split("/").slice(0, -1).join("/")
|
||||
if (!ops.isDirLoaded(parent)) return
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Project } from "@/types"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
|
||||
|
||||
describe("applyGlobalEvent", () => {
|
||||
test("upserts project.updated in sorted position", () => {
|
||||
const projects = [{ id: "b", worktree: "/b" }] as Project[]
|
||||
let next = projects
|
||||
applyGlobalEvent({
|
||||
event: { type: "project.updated", properties: { id: "a", worktree: "/a" } },
|
||||
project: projects,
|
||||
setGlobalProject: (value) => {
|
||||
next = typeof value === "function" ? value(next) : value
|
||||
},
|
||||
refresh() {},
|
||||
})
|
||||
expect(next.map((project) => project.id)).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("refreshes on global disposal", () => {
|
||||
let refreshed = false
|
||||
applyGlobalEvent({
|
||||
event: { type: "global.disposed" },
|
||||
project: [],
|
||||
setGlobalProject() {},
|
||||
refresh: () => (refreshed = true),
|
||||
})
|
||||
expect(refreshed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyDirectoryEvent", () => {
|
||||
test("updates vcs and routes refresh events", () => {
|
||||
const [store, setStore] = createStore({ vcs: { branch: "old" } } as State)
|
||||
const pushed: string[] = []
|
||||
let lsp = 0
|
||||
let references = 0
|
||||
const apply = (type: string, properties?: unknown) =>
|
||||
applyDirectoryEvent({
|
||||
event: { type, properties },
|
||||
store,
|
||||
setStore,
|
||||
directory: "/repo",
|
||||
push: (directory) => pushed.push(directory),
|
||||
loadLsp: () => lsp++,
|
||||
loadReferences: () => references++,
|
||||
})
|
||||
|
||||
apply("vcs.branch.updated", { branch: "main" })
|
||||
apply("server.instance.disposed")
|
||||
apply("lsp.updated")
|
||||
apply("reference.updated")
|
||||
|
||||
expect(store.vcs?.branch).toBe("main")
|
||||
expect(pushed).toEqual(["/repo"])
|
||||
expect(lsp).toBe(1)
|
||||
expect(references).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { produce, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Project } from "@/types"
|
||||
import type { State, VcsCache } from "./types"
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
project: Project[]
|
||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
|
||||
refresh: () => void
|
||||
}) {
|
||||
if (input.event.type === "global.disposed") {
|
||||
input.refresh()
|
||||
return
|
||||
}
|
||||
if (input.event.type !== "project.updated") return
|
||||
const properties = input.event.properties as Project
|
||||
const result = Binary.search(input.project, properties.id, (project) => project.id)
|
||||
if (result.found) {
|
||||
input.setGlobalProject(
|
||||
produce((draft) => {
|
||||
draft[result.index] = { ...draft[result.index], ...properties }
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
input.setGlobalProject(
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, properties)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function applyDirectoryEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
push: (directory: string) => void
|
||||
directory: string
|
||||
loadLsp: () => void
|
||||
loadReferences?: () => void
|
||||
vcsCache?: VcsCache
|
||||
}) {
|
||||
switch (input.event.type) {
|
||||
case "server.instance.disposed":
|
||||
input.push(input.directory)
|
||||
break
|
||||
case "vcs.branch.updated": {
|
||||
const properties = input.event.properties as { branch?: string }
|
||||
if (input.store.vcs?.branch === properties.branch) break
|
||||
const next = { ...input.store.vcs, branch: properties.branch }
|
||||
input.setStore("vcs", next)
|
||||
input.vcsCache?.setStore("value", next)
|
||||
break
|
||||
}
|
||||
case "lsp.updated":
|
||||
input.loadLsp()
|
||||
break
|
||||
case "reference.updated":
|
||||
input.loadReferences?.()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,10 @@ function createServerController(
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const data = createData({
|
||||
api: () => sdk.api,
|
||||
event: sdk.event,
|
||||
event: {
|
||||
on: sdk.event.on,
|
||||
listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })),
|
||||
},
|
||||
connection: sdk.connection,
|
||||
directory: "",
|
||||
})
|
||||
|
||||
@@ -3,12 +3,12 @@ import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, o
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { ServerSDK } from "./server-sdk"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import type { EventSessionError } from "@/types"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
import { useGlobal } from "./global"
|
||||
@@ -32,7 +32,7 @@ type TurnCompleteNotification = NotificationBase & {
|
||||
|
||||
type ErrorNotification = NotificationBase & {
|
||||
type: "error"
|
||||
error: EventSessionError["properties"]["error"]
|
||||
error: Extract<OpenCodeEvent, { type: "session.execution.failed" }>["data"]["error"]
|
||||
}
|
||||
|
||||
export type Notification = TurnCompleteNotification | ErrorNotification
|
||||
@@ -216,8 +216,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
dispatchEvent(new PopStateEvent("popstate"))
|
||||
}
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
|
||||
void lookup(sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
@@ -246,10 +245,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
|
||||
const handleSessionError = (
|
||||
directory: string,
|
||||
event: { properties: EventSessionError["properties"] },
|
||||
sessionID: string,
|
||||
error: ErrorNotification["error"],
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
@@ -258,27 +257,25 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = event.properties.error
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(sessionID),
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
session: sessionID,
|
||||
error,
|
||||
})
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionHref(input.key, sessionID ?? "global")
|
||||
const href = sessionHref(input.key, sessionID)
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = input.sdk.eventByDir.listen((e) => {
|
||||
const event = e.details
|
||||
const unsub = input.sdk.event.listen((event) => {
|
||||
if (
|
||||
event.type !== "session.execution.succeeded" &&
|
||||
event.type !== "session.execution.interrupted" &&
|
||||
@@ -286,14 +283,14 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
)
|
||||
return
|
||||
|
||||
const directory = event.current?.location?.directory
|
||||
const directory = event.location?.directory
|
||||
if (!directory) return
|
||||
const time = Date.now()
|
||||
if (event.type === "session.execution.failed") {
|
||||
handleSessionError(directory, event, time)
|
||||
handleSessionError(directory, event.data.sessionID, event.data.error, time)
|
||||
return
|
||||
}
|
||||
handleSessionIdle(directory, event, time)
|
||||
handleSessionIdle(directory, event.data.sessionID, time)
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
|
||||
@@ -54,8 +54,6 @@ function hasPermissionPromptRules(permission: unknown) {
|
||||
return Object.values(config).some(isNonAllowRule)
|
||||
}
|
||||
|
||||
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
|
||||
|
||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
@@ -204,20 +202,14 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
return next
|
||||
}
|
||||
|
||||
const handlePermission = (e: PermissionEvent) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
void respondPending(event.properties, event.current?.location?.directory)
|
||||
}
|
||||
|
||||
const unsubscribe = input.sdk.eventByDir.listen((event) => {
|
||||
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
|
||||
if (ready()) {
|
||||
handlePermission(event)
|
||||
void respondPending(event.data, event.location?.directory)
|
||||
return
|
||||
}
|
||||
void ready.promise?.then(() => {
|
||||
if (meta.disposed) return
|
||||
handlePermission(event)
|
||||
void respondPending(event.data, event.location?.directory)
|
||||
})
|
||||
})
|
||||
onCleanup(() => {
|
||||
|
||||
@@ -1,33 +1,86 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createOpenCodeEventSource } from "./server-sdk"
|
||||
|
||||
describe("adaptServerEvent", () => {
|
||||
test("preserves current permission requests", () => {
|
||||
const current = {
|
||||
id: "evt_1",
|
||||
created: 1,
|
||||
type: "permission.asked",
|
||||
data: {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
resources: ["src/**"],
|
||||
source: { type: "tool", messageID: "msg_1", id: "call_1" },
|
||||
},
|
||||
} as OpenCodeEvent
|
||||
const permission = {
|
||||
id: "evt_permission",
|
||||
created: 1,
|
||||
type: "permission.asked",
|
||||
location: { directory: "/repo", workspaceID: "workspace_1" },
|
||||
data: {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
resources: ["src/**"],
|
||||
source: { type: "tool", messageID: "msg_1", id: "call_1" },
|
||||
},
|
||||
} satisfies Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
|
||||
expect(adaptServerEvent(current)).toMatchObject({
|
||||
id: "evt_1",
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
resources: ["src/**"],
|
||||
source: { type: "tool", messageID: "msg_1", id: "call_1" },
|
||||
},
|
||||
current,
|
||||
function setup() {
|
||||
return createRoot((dispose) => ({ ...createOpenCodeEventSource(), dispose }))
|
||||
}
|
||||
|
||||
describe("server event stream", () => {
|
||||
test("publishes the original current event with exact data", () => {
|
||||
const server = setup()
|
||||
const received: OpenCodeEvent[] = []
|
||||
let requestID: string | undefined
|
||||
|
||||
server.event.on("permission.asked", (event) => {
|
||||
requestID = event.data.id
|
||||
})
|
||||
server.event.listen((event) => received.push(event))
|
||||
server.publish(permission)
|
||||
|
||||
expect(requestID).toBe("perm_1")
|
||||
expect(received).toEqual([permission])
|
||||
expect(received[0]).toBe(permission)
|
||||
server.dispose()
|
||||
})
|
||||
|
||||
test("filters locations without changing workspace identity", () => {
|
||||
const server = setup()
|
||||
const repo: OpenCodeEvent[] = []
|
||||
const other: OpenCodeEvent[] = []
|
||||
const all: OpenCodeEvent[] = []
|
||||
let workspaceID: string | undefined
|
||||
const global = {
|
||||
id: "evt_connected",
|
||||
type: "server.connected",
|
||||
data: {},
|
||||
} satisfies Extract<OpenCodeEvent, { type: "server.connected" }>
|
||||
|
||||
const repoEvents = server.event.location("/repo")
|
||||
repoEvents.on("permission.asked", (event) => {
|
||||
workspaceID = event.location?.workspaceID
|
||||
})
|
||||
repoEvents.listen((event) => repo.push(event))
|
||||
server.event.location("/other").listen((event) => other.push(event))
|
||||
server.event.listen((event) => all.push(event))
|
||||
server.publish(permission)
|
||||
server.publish(global)
|
||||
|
||||
expect(repo).toEqual([permission])
|
||||
expect(workspaceID).toBe("workspace_1")
|
||||
expect(other).toEqual([])
|
||||
expect(all).toEqual([permission, global])
|
||||
server.dispose()
|
||||
})
|
||||
|
||||
test("isolates servers and clears subscriptions with their owner", () => {
|
||||
const first = setup()
|
||||
const second = setup()
|
||||
const received = { first: 0, second: 0 }
|
||||
|
||||
first.event.listen(() => received.first++)
|
||||
second.event.listen(() => received.second++)
|
||||
first.publish(permission)
|
||||
first.dispose()
|
||||
first.publish(permission)
|
||||
second.publish(permission)
|
||||
|
||||
expect(received).toEqual({ first: 1, second: 1 })
|
||||
second.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
||||
import type { Event } from "@/types"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { type Accessor, onCleanup } from "solid-js"
|
||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||
@@ -10,15 +9,52 @@ import { createRefCountMap } from "@/utils/refcount"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { useServer } from "./server"
|
||||
|
||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
|
||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
export type OpenCodeEventStream = {
|
||||
on<Type extends OpenCodeEvent["type"]>(type: Type, handler: (event: OpenCodeEventMap[Type]) => void): VoidFunction
|
||||
listen(handler: (event: OpenCodeEvent) => void): VoidFunction
|
||||
}
|
||||
|
||||
type OpenCodeEventSource = OpenCodeEventStream & {
|
||||
location(directory: string): OpenCodeEventStream
|
||||
}
|
||||
|
||||
export function createOpenCodeEventSource() {
|
||||
const emitter = createGlobalEmitter<OpenCodeEventMap>()
|
||||
|
||||
function stream(directory?: string): OpenCodeEventStream {
|
||||
return {
|
||||
on(type, handler) {
|
||||
return emitter.on(type, (event) => {
|
||||
if (directory !== undefined && event.location?.directory !== directory) return
|
||||
handler(event)
|
||||
})
|
||||
},
|
||||
listen(handler) {
|
||||
return emitter.listen((event) => {
|
||||
if (directory !== undefined && event.details.location?.directory !== directory) return
|
||||
handler(event.details)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const event: OpenCodeEventSource = {
|
||||
...stream(),
|
||||
location: (directory) => stream(directory),
|
||||
}
|
||||
|
||||
onCleanup(() => emitter.clear())
|
||||
|
||||
return {
|
||||
event,
|
||||
publish(event: OpenCodeEvent) {
|
||||
emitter.emit(event.type, event)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
|
||||
export type ServerConnectionStatus = ClientConnectionStatus
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
@@ -30,28 +66,19 @@ type ServerSDKBase = {
|
||||
attempt: Accessor<number>
|
||||
error: Accessor<string | undefined>
|
||||
}
|
||||
eventByDir: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
}
|
||||
event: {
|
||||
on: CurrentEventEmitter["on"]
|
||||
listen: CurrentEventEmitter["listen"]
|
||||
}
|
||||
event: OpenCodeEventSource
|
||||
}
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
|
||||
const emitter = createGlobalEmitter<CurrentEventMap>()
|
||||
const events = createOpenCodeEventSource()
|
||||
|
||||
const connection = createClientConnection(api, {
|
||||
flushInterval: 16,
|
||||
pageLifecycle: true,
|
||||
onEvent(event) {
|
||||
emitter.emit(event.type, event)
|
||||
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
|
||||
events.publish(event)
|
||||
},
|
||||
log: {
|
||||
info(message, data) {
|
||||
@@ -61,25 +88,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
},
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
dirEmitter.clear()
|
||||
emitter.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
url: server.http.url,
|
||||
api,
|
||||
connection,
|
||||
eventByDir: {
|
||||
on: dirEmitter.on.bind(dirEmitter),
|
||||
listen: dirEmitter.listen.bind(dirEmitter),
|
||||
},
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
},
|
||||
event: events.event,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,25 +114,14 @@ export const useServerSDK = () => {
|
||||
return server.ctx.sdk
|
||||
}
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||
}
|
||||
|
||||
export type LocationContext = {
|
||||
directory: string
|
||||
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
|
||||
event: OpenCodeEventStream
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): LocationContext {
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
const unsub = serverSDK.eventByDir.on(directory, (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
return {
|
||||
directory,
|
||||
event: emitter,
|
||||
event: serverSDK.event.location(directory),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { type ServerSDK } from "./server-sdk"
|
||||
import { bootstrapDirectory, bootstrapGlobal, loadGlobalConfigQuery, loadPathQuery } from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
@@ -219,51 +218,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
return promise
|
||||
}
|
||||
|
||||
const unsub = serverSDK.eventByDir.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const eventType: string = event.type
|
||||
connection.handleEvent({ type: eventType })
|
||||
const unsub = serverSDK.event.listen((event) => {
|
||||
connection.handleEvent({ type: event.type })
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
refresh: () => void bootstrap.refetch(),
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (eventType === "config.updated" || eventType === "agent.updated" || eventType === "worktree.updated")
|
||||
if (!event.location) {
|
||||
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
|
||||
bootstrap.refetch()
|
||||
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
|
||||
return
|
||||
}
|
||||
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
const directory = event.location.directory
|
||||
const key = directoryKey(directory)
|
||||
if (!children.children[key]) return
|
||||
children.mark(key)
|
||||
if (eventType === "config.updated" || eventType === "agent.updated") queue.push(key)
|
||||
const [store, setStore] = existing
|
||||
if (eventType === "worktree.updated") void bootstrap.refetch()
|
||||
if (eventType !== "vcs.branch.updated")
|
||||
applyDirectoryEvent({
|
||||
event,
|
||||
directory,
|
||||
store,
|
||||
setStore,
|
||||
push: (directory) => {
|
||||
if (children.active(directory)) queue.push(directory)
|
||||
},
|
||||
vcsCache: children.vcsCache.get(key),
|
||||
loadLsp: () => {
|
||||
if (!children.active(key)) return
|
||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||
},
|
||||
loadReferences: () => {
|
||||
if (!children.active(key)) return
|
||||
void data.location.reference.sync({ directory: key }).catch(() => undefined)
|
||||
},
|
||||
})
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
|
||||
if (event.type === "worktree.updated") void bootstrap.refetch()
|
||||
if (event.type === "reference.updated" && children.active(key))
|
||||
void data.location.reference.sync({ directory: key }).catch(() => undefined)
|
||||
})
|
||||
|
||||
onCleanup(unsub)
|
||||
|
||||
@@ -215,8 +215,8 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
|
||||
removeExited(event.properties.id)
|
||||
const unsub = sdk.event.on("pty.exited", (event) => {
|
||||
removeExited(event.data.id)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
|
||||
@@ -616,7 +616,7 @@ export default function Page() {
|
||||
}, 100)
|
||||
onCleanup(
|
||||
sdk().event.listen((event) => {
|
||||
if (event.details.type === "filesystem.changed") refreshVcs()
|
||||
if (event.type === "filesystem.changed") refreshVcs()
|
||||
}),
|
||||
)
|
||||
createEffect(
|
||||
|
||||
@@ -52,13 +52,13 @@ export function useUsageExceededDialogs() {
|
||||
|
||||
onCleanup(
|
||||
sdk().event.on("session.status", (evt) => {
|
||||
if (evt.properties.sessionID !== params.id) return
|
||||
if (evt.properties.status.type !== "retry") return
|
||||
const { action } = evt.properties.status
|
||||
if (evt.data.sessionID !== params.id) return
|
||||
if (evt.data.status.type !== "retry") return
|
||||
const { action } = evt.data.status
|
||||
if (!action) return
|
||||
if (dialog.active) return
|
||||
|
||||
const keys = goUpsellKeys(evt.properties.status)
|
||||
const keys = goUpsellKeys(evt.data.status)
|
||||
if (!keys) return
|
||||
|
||||
const seen = goUpsellState[keys.lastSeenAt]
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
FileDiffInfo,
|
||||
ProjectListOutput,
|
||||
WorktreeDirectory,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo, ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
|
||||
export type Project = Omit<ProjectListOutput[number], "canonical"> & {
|
||||
@@ -11,16 +6,6 @@ export type Project = Omit<ProjectListOutput[number], "canonical"> & {
|
||||
worktrees: WorktreeDirectory[]
|
||||
}
|
||||
|
||||
type CurrentEvent = EventSubscribeOutput extends infer Item
|
||||
? Item extends { type: infer Type extends string; data: infer Data }
|
||||
? { type: Type; properties: Data }
|
||||
: never
|
||||
: never
|
||||
|
||||
export type Event = CurrentEvent
|
||||
|
||||
export type EventSessionError = Extract<Event, { type: "session.execution.failed" }>
|
||||
|
||||
type MessageError =
|
||||
| { name: "ProviderAuthError"; data: { providerID: string; message: string } }
|
||||
| { name: "UnknownError"; data: { message: string; ref?: string } }
|
||||
|
||||
Reference in New Issue
Block a user