refactor(tui): switch to global events and start passing workspace param (#21719)
This commit is contained in:
@@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
|
|||||||
event: [
|
event: [
|
||||||
{
|
{
|
||||||
directory?: string
|
directory?: string
|
||||||
|
project?: string
|
||||||
|
workspace?: string
|
||||||
payload: any
|
payload: any
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
|
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { BusEvent } from "./bus-event"
|
import { BusEvent } from "./bus-event"
|
||||||
import { GlobalBus } from "./global"
|
import { GlobalBus } from "./global"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
@@ -91,8 +91,13 @@ export namespace Bus {
|
|||||||
yield* PubSub.publish(s.wildcard, payload)
|
yield* PubSub.publish(s.wildcard, payload)
|
||||||
|
|
||||||
const dir = yield* InstanceState.directory
|
const dir = yield* InstanceState.directory
|
||||||
|
const context = yield* InstanceState.context
|
||||||
|
const workspace = yield* InstanceState.workspaceID
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: dir,
|
directory: dir,
|
||||||
|
project: context.project.id,
|
||||||
|
workspace,
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
batch,
|
batch,
|
||||||
Show,
|
Show,
|
||||||
on,
|
on,
|
||||||
onCleanup,
|
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
@@ -23,6 +22,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
|||||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||||
import { ErrorComponent } from "@tui/component/error-component"
|
import { ErrorComponent } from "@tui/component/error-component"
|
||||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||||
|
import { ProjectProvider } from "@tui/context/project"
|
||||||
|
import { useEvent } from "@tui/context/event"
|
||||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||||
import { StartupLoading } from "@tui/component/startup-loading"
|
import { StartupLoading } from "@tui/component/startup-loading"
|
||||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||||
@@ -54,7 +55,6 @@ import { KVProvider, useKV } from "./context/kv"
|
|||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { writeHeapSnapshot } from "v8"
|
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config/tui"
|
||||||
@@ -216,6 +216,7 @@ export function tui(input: {
|
|||||||
headers={input.headers}
|
headers={input.headers}
|
||||||
events={input.events}
|
events={input.events}
|
||||||
>
|
>
|
||||||
|
<ProjectProvider>
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
<ThemeProvider mode={mode}>
|
<ThemeProvider mode={mode}>
|
||||||
<LocalProvider>
|
<LocalProvider>
|
||||||
@@ -237,6 +238,7 @@ export function tui(input: {
|
|||||||
</LocalProvider>
|
</LocalProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</SyncProvider>
|
</SyncProvider>
|
||||||
|
</ProjectProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
</TuiConfigProvider>
|
</TuiConfigProvider>
|
||||||
</RouteProvider>
|
</RouteProvider>
|
||||||
@@ -260,6 +262,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const command = useCommandDialog()
|
const command = useCommandDialog()
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
|
const event = useEvent()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const themeState = useTheme()
|
const themeState = useTheme()
|
||||||
@@ -283,6 +286,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
route,
|
route,
|
||||||
routes,
|
routes,
|
||||||
bump: () => setRouteRev((x) => x + 1),
|
bump: () => setRouteRev((x) => x + 1),
|
||||||
|
event,
|
||||||
sdk,
|
sdk,
|
||||||
sync,
|
sync,
|
||||||
theme: themeState,
|
theme: themeState,
|
||||||
@@ -491,12 +495,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
const current = promptRef.current
|
const current = promptRef.current
|
||||||
// Don't require focus - if there's any text, preserve it
|
// Don't require focus - if there's any text, preserve it
|
||||||
const currentPrompt = current?.current?.input ? current.current : undefined
|
const currentPrompt = current?.current?.input ? current.current : undefined
|
||||||
const workspaceID =
|
|
||||||
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
|
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "home",
|
type: "home",
|
||||||
initialPrompt: currentPrompt,
|
initialPrompt: currentPrompt,
|
||||||
workspaceID,
|
|
||||||
})
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -806,11 +807,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
|
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||||
command.trigger(evt.properties.command)
|
command.trigger(evt.properties.command)
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
|
event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||||
toast.show({
|
toast.show({
|
||||||
title: evt.properties.title,
|
title: evt.properties.title,
|
||||||
message: evt.properties.message,
|
message: evt.properties.message,
|
||||||
@@ -819,14 +820,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
|
event.on(TuiEvent.SessionSelect.type, (evt) => {
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "session",
|
type: "session",
|
||||||
sessionID: evt.properties.sessionID,
|
sessionID: evt.properties.sessionID,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on("session.deleted", (evt) => {
|
event.on("session.deleted", (evt) => {
|
||||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||||
route.navigate({ type: "home" })
|
route.navigate({ type: "home" })
|
||||||
toast.show({
|
toast.show({
|
||||||
@@ -836,7 +837,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on("session.error", (evt) => {
|
event.on("session.error", (evt) => {
|
||||||
const error = evt.properties.error
|
const error = evt.properties.error
|
||||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||||
const message = errorMessage(error)
|
const message = errorMessage(error)
|
||||||
@@ -848,7 +849,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on("installation.update-available", async (evt) => {
|
event.on("installation.update-available", async (evt) => {
|
||||||
const version = evt.properties.version
|
const version = evt.properties.version
|
||||||
|
|
||||||
const skipped = kv.get("skipped_version")
|
const skipped = kv.get("skipped_version")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useDialog } from "@tui/ui/dialog"
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||||
@@ -14,7 +15,7 @@ function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>
|
|||||||
return createOpencodeClient({
|
return createOpencodeClient({
|
||||||
baseUrl: sdk.url,
|
baseUrl: sdk.url,
|
||||||
fetch: sdk.fetch,
|
fetch: sdk.fetch,
|
||||||
directory: sync.data.path.directory || sdk.directory,
|
directory: sync.path.directory || sdk.directory,
|
||||||
experimental_workspaceID: workspaceID,
|
experimental_workspaceID: workspaceID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -149,6 +150,7 @@ function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promi
|
|||||||
|
|
||||||
export function DialogWorkspaceList() {
|
export function DialogWorkspaceList() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const project = useProject()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -168,8 +170,9 @@ export function DialogWorkspaceList() {
|
|||||||
forceCreate,
|
forceCreate,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function selectWorkspace(workspaceID: string) {
|
async function selectWorkspace(workspaceID: string | null) {
|
||||||
if (workspaceID === "__local__") {
|
if (workspaceID == null) {
|
||||||
|
project.workspace.set(undefined)
|
||||||
if (localCount() > 0) {
|
if (localCount() > 0) {
|
||||||
dialog.replace(() => <DialogSessionList localOnly={true} />)
|
dialog.replace(() => <DialogSessionList localOnly={true} />)
|
||||||
return
|
return
|
||||||
@@ -199,12 +202,7 @@ export function DialogWorkspaceList() {
|
|||||||
await open(workspaceID)
|
await open(workspaceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentWorkspaceID = createMemo(() => {
|
const currentWorkspaceID = createMemo(() => project.workspace.current())
|
||||||
if (route.data.type === "session") {
|
|
||||||
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
|
|
||||||
}
|
|
||||||
return "__local__"
|
|
||||||
})
|
|
||||||
|
|
||||||
const localCount = createMemo(
|
const localCount = createMemo(
|
||||||
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
|
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
|
||||||
@@ -234,7 +232,7 @@ export function DialogWorkspaceList() {
|
|||||||
const options = createMemo(() => [
|
const options = createMemo(() => [
|
||||||
{
|
{
|
||||||
title: "Local",
|
title: "Local",
|
||||||
value: "__local__",
|
value: null,
|
||||||
category: "Workspace",
|
category: "Workspace",
|
||||||
description: "Use the local machine",
|
description: "Use the local machine",
|
||||||
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
|
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
|
||||||
@@ -292,7 +290,7 @@ export function DialogWorkspaceList() {
|
|||||||
keybind: keybind.all.session_delete?.[0],
|
keybind: keybind.all.session_delete?.[0],
|
||||||
title: "delete",
|
title: "delete",
|
||||||
onTrigger: async (option) => {
|
onTrigger: async (option) => {
|
||||||
if (option.value === "__create__" || option.value === "__local__") return
|
if (option.value === "__create__" || option.value === null) return
|
||||||
if (toDelete() !== option.value) {
|
if (toDelete() !== option.value) {
|
||||||
setToDelete(option.value)
|
setToDelete(option.value)
|
||||||
return
|
return
|
||||||
@@ -307,6 +305,7 @@ export function DialogWorkspaceList() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (currentWorkspaceID() === option.value) {
|
if (currentWorkspaceID() === option.value) {
|
||||||
|
project.workspace.set(undefined)
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "home",
|
type: "home",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export function Autocomplete(props: {
|
|||||||
const width = props.anchor().width - 4
|
const width = props.anchor().width - 4
|
||||||
options.push(
|
options.push(
|
||||||
...sortedFiles.map((item): AutocompleteOption => {
|
...sortedFiles.map((item): AutocompleteOption => {
|
||||||
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
|
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
|
||||||
const fullPath = `${baseDir}/${item}`
|
const fullPath = `${baseDir}/${item}`
|
||||||
const urlObj = pathToFileURL(fullPath)
|
const urlObj = pathToFileURL(fullPath)
|
||||||
let filename = item
|
let filename = item
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
|||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
|
import { useEvent } from "@tui/context/event"
|
||||||
import { MessageID, PartID } from "@/session/schema"
|
import { MessageID, PartID } from "@/session/schema"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useKeybind } from "@tui/context/keybind"
|
import { useKeybind } from "@tui/context/keybind"
|
||||||
@@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
||||||
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||||
let promptPartTypeId = 0
|
let promptPartTypeId = 0
|
||||||
|
const event = useEvent()
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
input.insertText(evt.properties.text)
|
input.insertText(evt.properties.text)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
|
import { useProject } from "./project"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
|
|
||||||
export function useDirectory() {
|
export function useDirectory() {
|
||||||
|
const project = useProject()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
return createMemo(() => {
|
return createMemo(() => {
|
||||||
const directory = sync.data.path.directory || process.cwd()
|
const directory = project.instance.path().directory || process.cwd()
|
||||||
const result = directory.replace(Global.Path.home, "~")
|
const result = directory.replace(Global.Path.home, "~")
|
||||||
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Event } from "@opencode-ai/sdk/v2"
|
||||||
|
import { useProject } from "./project"
|
||||||
|
import { useSDK } from "./sdk"
|
||||||
|
|
||||||
|
export function useEvent() {
|
||||||
|
const project = useProject()
|
||||||
|
const sdk = useSDK()
|
||||||
|
|
||||||
|
function subscribe(handler: (event: Event) => void) {
|
||||||
|
return sdk.event.on("event", (event) => {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
|
||||||
|
return subscribe((event) => {
|
||||||
|
if (event.type !== type) return
|
||||||
|
handler(event as Extract<Event, { type: T }>)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
on,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { batch } from "solid-js"
|
||||||
|
import type { Path } from "@opencode-ai/sdk"
|
||||||
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
|
import { createSimpleContext } from "./helper"
|
||||||
|
import { useSDK } from "./sdk"
|
||||||
|
|
||||||
|
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
|
||||||
|
name: "Project",
|
||||||
|
init: () => {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
project: {
|
||||||
|
id: undefined as string | undefined,
|
||||||
|
},
|
||||||
|
instance: {
|
||||||
|
path: {
|
||||||
|
state: "",
|
||||||
|
config: "",
|
||||||
|
worktree: "",
|
||||||
|
directory: sdk.directory ?? "",
|
||||||
|
} satisfies Path,
|
||||||
|
},
|
||||||
|
workspace: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function sync() {
|
||||||
|
const workspace = store.workspace
|
||||||
|
const [path, project] = await Promise.all([
|
||||||
|
sdk.client.path.get({ workspace }),
|
||||||
|
sdk.client.project.current({ workspace }),
|
||||||
|
])
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setStore("instance", "path", reconcile(path.data!))
|
||||||
|
setStore("project", "id", project.data?.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: store,
|
||||||
|
project() {
|
||||||
|
return store.project.id
|
||||||
|
},
|
||||||
|
instance: {
|
||||||
|
path() {
|
||||||
|
return store.instance.path
|
||||||
|
},
|
||||||
|
directory() {
|
||||||
|
return store.instance.path.directory
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
current() {
|
||||||
|
return store.workspace
|
||||||
|
},
|
||||||
|
set(next?: string | null) {
|
||||||
|
const workspace = next ?? undefined
|
||||||
|
if (store.workspace === workspace) return
|
||||||
|
setStore("workspace", workspace)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sync,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history"
|
|||||||
export type HomeRoute = {
|
export type HomeRoute = {
|
||||||
type: "home"
|
type: "home"
|
||||||
initialPrompt?: PromptInfo
|
initialPrompt?: PromptInfo
|
||||||
workspaceID?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionRoute = {
|
export type SessionRoute = {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
|
|
||||||
export type EventSource = {
|
export type EventSource = {
|
||||||
subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void>
|
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
@@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
let sdk = createSDK()
|
let sdk = createSDK()
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
event: GlobalEvent
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
let queue: Event[] = []
|
let queue: GlobalEvent[] = []
|
||||||
let timer: Timer | undefined
|
let timer: Timer | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
|
|
||||||
@@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
// Batch all event emissions so all store updates result in a single render
|
// Batch all event emissions so all store updates result in a single render
|
||||||
batch(() => {
|
batch(() => {
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
emitter.emit(event.type, event)
|
emitter.emit("event", event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEvent = (event: Event) => {
|
const handleEvent = (event: GlobalEvent) => {
|
||||||
queue.push(event)
|
queue.push(event)
|
||||||
const elapsed = Date.now() - last
|
const elapsed = Date.now() - last
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
;(async () => {
|
;(async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||||
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
|
const events = await sdk.global.event({ signal: ctrl.signal })
|
||||||
|
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
if (ctrl.signal.aborted) break
|
if (ctrl.signal.aborted) break
|
||||||
@@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (props.events) {
|
if (props.events) {
|
||||||
const unsub = await props.events.subscribe(props.directory, handleEvent)
|
const unsub = await props.events.subscribe(handleEvent)
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
} else {
|
} else {
|
||||||
startSSE()
|
startSSE()
|
||||||
|
|||||||
@@ -17,18 +17,19 @@ import type {
|
|||||||
ProviderListResponse,
|
ProviderListResponse,
|
||||||
ProviderAuthMethod,
|
ProviderAuthMethod,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
|
Workspace,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
|
import { useEvent } from "@tui/context/event"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import type { Snapshot } from "@/snapshot"
|
import type { Snapshot } from "@/snapshot"
|
||||||
import { useExit } from "./exit"
|
import { useExit } from "./exit"
|
||||||
import { useArgs } from "./args"
|
import { useArgs } from "./args"
|
||||||
import { batch, onMount } from "solid-js"
|
import { batch, createEffect, on } from "solid-js"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import type { Path } from "@opencode-ai/sdk"
|
|
||||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
|
||||||
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
@@ -74,9 +75,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
[key: string]: McpResource
|
[key: string]: McpResource
|
||||||
}
|
}
|
||||||
formatter: FormatterStatus[]
|
formatter: FormatterStatus[]
|
||||||
vcs: VcsInfo | undefined
|
|
||||||
path: Path
|
|
||||||
workspaceList: Workspace[]
|
workspaceList: Workspace[]
|
||||||
|
vcs: VcsInfo | undefined
|
||||||
}>({
|
}>({
|
||||||
provider_next: {
|
provider_next: {
|
||||||
all: [],
|
all: [],
|
||||||
@@ -103,21 +103,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
mcp: {},
|
mcp: {},
|
||||||
mcp_resource: {},
|
mcp_resource: {},
|
||||||
formatter: [],
|
formatter: [],
|
||||||
vcs: undefined,
|
|
||||||
path: { state: "", config: "", worktree: "", directory: "" },
|
|
||||||
workspaceList: [],
|
workspaceList: [],
|
||||||
|
vcs: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const event = useEvent()
|
||||||
|
const project = useProject()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
async function syncWorkspaces() {
|
async function syncWorkspaces() {
|
||||||
|
const workspace = project.workspace.current()
|
||||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||||
if (!result?.data) return
|
if (!result?.data) return
|
||||||
setStore("workspaceList", reconcile(result.data))
|
setStore("workspaceList", reconcile(result.data))
|
||||||
|
if (!result.data.some((item) => item.id === workspace)) {
|
||||||
|
project.workspace.set(undefined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sdk.event.listen((e) => {
|
event.subscribe((event) => {
|
||||||
const event = e.details
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "server.instance.disposed":
|
case "server.instance.disposed":
|
||||||
bootstrap()
|
bootstrap()
|
||||||
@@ -344,7 +348,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "lsp.updated": {
|
case "lsp.updated": {
|
||||||
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
|
const workspace = project.workspace.current()
|
||||||
|
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,25 +365,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
console.log("bootstrapping")
|
console.log("bootstrapping")
|
||||||
|
const workspace = project.workspace.current()
|
||||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||||
const sessionListPromise = sdk.client.session
|
const sessionListPromise = sdk.client.session
|
||||||
.list({ start: start })
|
.list({ start: start, workspace })
|
||||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||||
|
|
||||||
// blocking - include session.list when continuing a session
|
// blocking - include session.list when continuing a session
|
||||||
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
|
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
|
||||||
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
|
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
|
||||||
const consoleStatePromise = sdk.client.experimental.console
|
const consoleStatePromise = sdk.client.experimental.console
|
||||||
.get({}, { throwOnError: true })
|
.get({ workspace }, { throwOnError: true })
|
||||||
.then((x) => ConsoleState.parse(x.data))
|
.then((x) => ConsoleState.parse(x.data))
|
||||||
.catch(() => emptyConsoleState)
|
.catch(() => emptyConsoleState)
|
||||||
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
|
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||||
const configPromise = sdk.client.config.get({}, { throwOnError: true })
|
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||||
|
const projectPromise = project.sync()
|
||||||
const blockingRequests: Promise<unknown>[] = [
|
const blockingRequests: Promise<unknown>[] = [
|
||||||
providersPromise,
|
providersPromise,
|
||||||
providerListPromise,
|
providerListPromise,
|
||||||
agentsPromise,
|
agentsPromise,
|
||||||
configPromise,
|
configPromise,
|
||||||
|
projectPromise,
|
||||||
...(args.continue ? [sessionListPromise] : []),
|
...(args.continue ? [sessionListPromise] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -423,17 +431,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
|
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
|
||||||
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
|
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
|
||||||
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
|
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||||
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
|
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
|
||||||
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
|
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
|
||||||
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
sdk.client.experimental.resource
|
||||||
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
|
.list({ workspace })
|
||||||
sdk.client.session.status().then((x) => {
|
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||||
|
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
|
||||||
|
sdk.client.session.status({ workspace }).then((x) => {
|
||||||
setStore("session_status", reconcile(x.data!))
|
setStore("session_status", reconcile(x.data!))
|
||||||
}),
|
}),
|
||||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||||
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
|
||||||
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
|
||||||
syncWorkspaces(),
|
syncWorkspaces(),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
setStore("status", "complete")
|
setStore("status", "complete")
|
||||||
@@ -449,11 +458,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
bootstrap()
|
|
||||||
})
|
|
||||||
|
|
||||||
const fullSyncedSessions = new Set<string>()
|
const fullSyncedSessions = new Set<string>()
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => project.workspace.current(),
|
||||||
|
() => {
|
||||||
|
fullSyncedSessions.clear()
|
||||||
|
void bootstrap()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
data: store,
|
data: store,
|
||||||
set: setStore,
|
set: setStore,
|
||||||
@@ -463,6 +478,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
get ready() {
|
get ready() {
|
||||||
return store.status !== "loading"
|
return store.status !== "loading"
|
||||||
},
|
},
|
||||||
|
get path() {
|
||||||
|
return project.instance.path()
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
get(sessionID: string) {
|
get(sessionID: string) {
|
||||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||||
@@ -481,11 +499,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
async sync(sessionID: string) {
|
async sync(sessionID: string) {
|
||||||
if (fullSyncedSessions.has(sessionID)) return
|
if (fullSyncedSessions.has(sessionID)) return
|
||||||
|
const workspace = project.workspace.current()
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
|
||||||
sdk.client.session.messages({ sessionID, limit: 100 }),
|
sdk.client.session.messages({ sessionID, limit: 100, workspace }),
|
||||||
sdk.client.session.todo({ sessionID }),
|
sdk.client.session.todo({ sessionID, workspace }),
|
||||||
sdk.client.session.diff({ sessionID }),
|
sdk.client.session.diff({ sessionID, workspace }),
|
||||||
])
|
])
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -504,8 +523,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
|
list() {
|
||||||
|
return store.workspaceList
|
||||||
|
},
|
||||||
get(workspaceID: string) {
|
get(workspaceID: string) {
|
||||||
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
|
return store.workspaceList.find((item) => item.id === workspaceID)
|
||||||
},
|
},
|
||||||
sync: syncWorkspaces,
|
sync: syncWorkspaces,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ParsedKey } from "@opentui/core"
|
import type { ParsedKey } from "@opentui/core"
|
||||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||||
import type { useCommandDialog } from "@tui/component/dialog-command"
|
import type { useCommandDialog } from "@tui/component/dialog-command"
|
||||||
|
import type { useEvent } from "@tui/context/event"
|
||||||
import type { useKeybind } from "@tui/context/keybind"
|
import type { useKeybind } from "@tui/context/keybind"
|
||||||
import type { useRoute } from "@tui/context/route"
|
import type { useRoute } from "@tui/context/route"
|
||||||
import type { useSDK } from "@tui/context/sdk"
|
import type { useSDK } from "@tui/context/sdk"
|
||||||
@@ -36,6 +37,7 @@ type Input = {
|
|||||||
route: ReturnType<typeof useRoute>
|
route: ReturnType<typeof useRoute>
|
||||||
routes: RouteMap
|
routes: RouteMap
|
||||||
bump: () => void
|
bump: () => void
|
||||||
|
event: ReturnType<typeof useEvent>
|
||||||
sdk: ReturnType<typeof useSDK>
|
sdk: ReturnType<typeof useSDK>
|
||||||
sync: ReturnType<typeof useSync>
|
sync: ReturnType<typeof useSync>
|
||||||
theme: ReturnType<typeof useTheme>
|
theme: ReturnType<typeof useTheme>
|
||||||
@@ -136,7 +138,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
|||||||
return sync.data.provider
|
return sync.data.provider
|
||||||
},
|
},
|
||||||
get path() {
|
get path() {
|
||||||
return sync.data.path
|
return sync.path
|
||||||
},
|
},
|
||||||
get vcs() {
|
get vcs() {
|
||||||
if (!sync.data.vcs) return
|
if (!sync.data.vcs) return
|
||||||
@@ -342,7 +344,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
|
|||||||
get client() {
|
get client() {
|
||||||
return input.sdk.client
|
return input.sdk.client
|
||||||
},
|
},
|
||||||
event: input.sdk.event,
|
event: input.event,
|
||||||
renderer: input.renderer,
|
renderer: input.renderer,
|
||||||
slots: {
|
slots: {
|
||||||
register() {
|
register() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||||
import { createEffect, createSignal } from "solid-js"
|
import { createEffect, createSignal } from "solid-js"
|
||||||
import { Logo } from "../component/logo"
|
import { Logo } from "../component/logo"
|
||||||
|
import { useProject } from "../context/project"
|
||||||
import { useSync } from "../context/sync"
|
import { useSync } from "../context/sync"
|
||||||
import { Toast } from "../ui/toast"
|
import { Toast } from "../ui/toast"
|
||||||
import { useArgs } from "../context/args"
|
import { useArgs } from "../context/args"
|
||||||
@@ -18,6 +19,7 @@ const placeholder = {
|
|||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const project = useProject()
|
||||||
const route = useRouteData("home")
|
const route = useRouteData("home")
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||||
@@ -63,11 +65,16 @@ export function Home() {
|
|||||||
</box>
|
</box>
|
||||||
<box height={1} minHeight={0} flexShrink={1} />
|
<box height={1} minHeight={0} flexShrink={1} />
|
||||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||||
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
|
<TuiPluginRuntime.Slot
|
||||||
|
name="home_prompt"
|
||||||
|
mode="replace"
|
||||||
|
workspace_id={project.workspace.current()}
|
||||||
|
ref={bind}
|
||||||
|
>
|
||||||
<Prompt
|
<Prompt
|
||||||
ref={bind}
|
ref={bind}
|
||||||
workspaceID={route.workspaceID}
|
workspaceID={project.workspace.current()}
|
||||||
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
|
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
|
||||||
placeholders={placeholder}
|
placeholders={placeholder}
|
||||||
/>
|
/>
|
||||||
</TuiPluginRuntime.Slot>
|
</TuiPluginRuntime.Slot>
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { useRoute, useRouteData } from "@tui/context/route"
|
import { useRoute, useRouteData } from "@tui/context/route"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
|
import { useEvent } from "@tui/context/event"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder } from "@tui/component/border"
|
||||||
import { Spinner } from "@tui/component/spinner"
|
import { Spinner } from "@tui/component/spinner"
|
||||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||||
@@ -116,6 +118,8 @@ export function Session() {
|
|||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
const { navigate } = useRoute()
|
const { navigate } = useRoute()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const event = useEvent()
|
||||||
|
const project = useProject()
|
||||||
const tuiConfig = useTuiConfig()
|
const tuiConfig = useTuiConfig()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
@@ -172,10 +176,16 @@ export function Session() {
|
|||||||
const providers = createMemo(() => Model.index(sync.data.provider))
|
const providers = createMemo(() => Model.index(sync.data.provider))
|
||||||
|
|
||||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||||
|
const toast = useToast()
|
||||||
|
const sdk = useSDK()
|
||||||
|
|
||||||
createEffect(async () => {
|
createEffect(async () => {
|
||||||
await sync.session
|
await sdk.client.session
|
||||||
.sync(route.sessionID)
|
.get({ sessionID: route.sessionID }, { throwOnError: true })
|
||||||
|
.then((x) => {
|
||||||
|
project.workspace.set(x.data?.workspaceID)
|
||||||
|
})
|
||||||
|
.then(() => sync.session.sync(route.sessionID))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (scroll) scroll.scrollBy(100_000)
|
if (scroll) scroll.scrollBy(100_000)
|
||||||
})
|
})
|
||||||
@@ -189,13 +199,10 @@ export function Session() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const toast = useToast()
|
|
||||||
const sdk = useSDK()
|
|
||||||
|
|
||||||
// Handle initial prompt from fork
|
// Handle initial prompt from fork
|
||||||
let seeded = false
|
let seeded = false
|
||||||
let lastSwitch: string | undefined = undefined
|
let lastSwitch: string | undefined = undefined
|
||||||
sdk.event.on("message.part.updated", (evt) => {
|
event.on("message.part.updated", (evt) => {
|
||||||
const part = evt.properties.part
|
const part = evt.properties.part
|
||||||
if (part.type !== "tool") return
|
if (part.type !== "tool") return
|
||||||
if (part.sessionID !== route.sessionID) return
|
if (part.sessionID !== route.sessionID) return
|
||||||
@@ -224,7 +231,7 @@ export function Session() {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
||||||
sdk.event.on("session.status", (evt) => {
|
event.on("session.status", (evt) => {
|
||||||
if (evt.properties.sessionID !== route.sessionID) return
|
if (evt.properties.sessionID !== route.sessionID) return
|
||||||
if (evt.properties.status.type !== "retry") return
|
if (evt.properties.status.type !== "retry") return
|
||||||
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
|
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
|
||||||
@@ -1791,7 +1798,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
|
|||||||
const workdir = props.input.workdir
|
const workdir = props.input.workdir
|
||||||
if (!workdir || workdir === ".") return undefined
|
if (!workdir || workdir === ".") return undefined
|
||||||
|
|
||||||
const base = sync.data.path.directory
|
const base = sync.path.directory
|
||||||
if (!base) return undefined
|
if (!base) return undefined
|
||||||
|
|
||||||
const absolute = path.resolve(base, workdir)
|
const absolute = path.resolve(base, workdir)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
|
|||||||
import { withTimeout } from "@/util/timeout"
|
import { withTimeout } from "@/util/timeout"
|
||||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2"
|
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config/tui"
|
||||||
@@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
|
|||||||
|
|
||||||
function createEventSource(client: RpcClient): EventSource {
|
function createEventSource(client: RpcClient): EventSource {
|
||||||
return {
|
return {
|
||||||
subscribe: async (directory, handler) => {
|
subscribe: async (handler) => {
|
||||||
const id = await client.call("subscribe", { directory })
|
return client.on<GlobalEvent>("global.event", (e) => {
|
||||||
const unsub = client.on<{ id: string; event: Event }>("event", (e) => {
|
handler(e)
|
||||||
if (e.id === id) {
|
|
||||||
handler(e.event)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsub()
|
|
||||||
client.call("unsubscribe", { id })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap"
|
|||||||
import { Rpc } from "@/util/rpc"
|
import { Rpc } from "@/util/rpc"
|
||||||
import { upgrade } from "@/cli/upgrade"
|
import { upgrade } from "@/cli/upgrade"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2"
|
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
|
||||||
import { writeHeapSnapshot } from "node:v8"
|
import { writeHeapSnapshot } from "node:v8"
|
||||||
import { WorkspaceID } from "@/control-plane/schema"
|
|
||||||
import { Heap } from "@/cli/heap"
|
import { Heap } from "@/cli/heap"
|
||||||
|
|
||||||
await Log.init({
|
await Log.init({
|
||||||
@@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => {
|
|||||||
|
|
||||||
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||||
|
|
||||||
const eventStreams = new Map<string, AbortController>()
|
|
||||||
|
|
||||||
function startEventStream(directory: string) {
|
|
||||||
const id = crypto.randomUUID()
|
|
||||||
|
|
||||||
const abort = new AbortController()
|
|
||||||
const signal = abort.signal
|
|
||||||
|
|
||||||
eventStreams.set(id, abort)
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
while (!signal.aborted) {
|
|
||||||
const shouldReconnect = await Instance.provide({
|
|
||||||
directory,
|
|
||||||
init: InstanceBootstrap,
|
|
||||||
fn: () =>
|
|
||||||
new Promise<boolean>((resolve) => {
|
|
||||||
Rpc.emit("event", {
|
|
||||||
type: "server.connected",
|
|
||||||
properties: {},
|
|
||||||
} satisfies Event)
|
|
||||||
|
|
||||||
let settled = false
|
|
||||||
const settle = (value: boolean) => {
|
|
||||||
if (settled) return
|
|
||||||
settled = true
|
|
||||||
signal.removeEventListener("abort", onAbort)
|
|
||||||
unsub()
|
|
||||||
resolve(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsub = Bus.subscribeAll((event) => {
|
|
||||||
Rpc.emit("event", {
|
|
||||||
id,
|
|
||||||
event: event as Event,
|
|
||||||
})
|
|
||||||
if (event.type === Bus.InstanceDisposed.type) {
|
|
||||||
settle(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onAbort = () => {
|
|
||||||
settle(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
signal.addEventListener("abort", onAbort, { once: true })
|
|
||||||
}),
|
|
||||||
}).catch((error) => {
|
|
||||||
Log.Default.error("event stream subscribe error", {
|
|
||||||
error: error instanceof Error ? error.message : error,
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!shouldReconnect || signal.aborted) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signal.aborted) {
|
|
||||||
await sleep(250)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((error) => {
|
|
||||||
Log.Default.error("event stream error", {
|
|
||||||
error: error instanceof Error ? error.message : error,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopEventStream(id: string) {
|
|
||||||
const abortController = eventStreams.get(id)
|
|
||||||
if (!abortController) return
|
|
||||||
|
|
||||||
abortController.abort()
|
|
||||||
eventStreams.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rpc = {
|
export const rpc = {
|
||||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||||
const headers = { ...input.headers }
|
const headers = { ...input.headers }
|
||||||
@@ -167,19 +83,9 @@ export const rpc = {
|
|||||||
async reload() {
|
async reload() {
|
||||||
await Config.invalidate(true)
|
await Config.invalidate(true)
|
||||||
},
|
},
|
||||||
async subscribe(input: { directory: string | undefined }) {
|
|
||||||
return startEventStream(input.directory || process.cwd())
|
|
||||||
},
|
|
||||||
async unsubscribe(input: { id: string }) {
|
|
||||||
stopEventStream(input.id)
|
|
||||||
},
|
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
Log.Default.info("worker shutting down")
|
Log.Default.info("worker shutting down")
|
||||||
|
|
||||||
for (const id of [...eventStreams.keys()]) {
|
|
||||||
stopEventStream(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
if (server) await server.stop(true)
|
if (server) await server.stop(true)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Context } from "../util/context"
|
||||||
|
import type { WorkspaceID } from "../control-plane/schema"
|
||||||
|
|
||||||
|
export interface WorkspaceContext {
|
||||||
|
workspaceID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = Context.create<WorkspaceContext>("instance")
|
||||||
|
|
||||||
|
export const WorkspaceContext = {
|
||||||
|
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
|
||||||
|
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
|
||||||
|
},
|
||||||
|
|
||||||
|
get workspaceID() {
|
||||||
|
try {
|
||||||
|
return context.use().workspaceID
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -134,12 +134,12 @@ export namespace Workspace {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
await parseSSE(res.body, stop, (event) => {
|
// await parseSSE(res.body, stop, (event) => {
|
||||||
GlobalBus.emit("event", {
|
// GlobalBus.emit("event", {
|
||||||
directory: space.id,
|
// directory: space.id,
|
||||||
payload: event,
|
// payload: event,
|
||||||
})
|
// })
|
||||||
})
|
// })
|
||||||
|
|
||||||
// Wait 250ms and retry if SSE connection fails
|
// Wait 250ms and retry if SSE connection fails
|
||||||
await sleep(250)
|
await sleep(250)
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ import type { InstanceContext } from "@/project/instance"
|
|||||||
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
||||||
defaultValue: () => undefined,
|
defaultValue: () => undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const WorkspaceRef = ServiceMap.Reference<string | undefined>("~opencode/WorkspaceRef", {
|
||||||
|
defaultValue: () => undefined,
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
||||||
import { Instance, type InstanceContext } from "@/project/instance"
|
import { Instance, type InstanceContext } from "@/project/instance"
|
||||||
import { Context } from "@/util/context"
|
import { Context } from "@/util/context"
|
||||||
import { InstanceRef } from "./instance-ref"
|
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||||
import { registerDisposer } from "./instance-registry"
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
|
||||||
const TypeId = "~opencode/InstanceState"
|
const TypeId = "~opencode/InstanceState"
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ export namespace InstanceState {
|
|||||||
return (yield* InstanceRef) ?? Instance.current
|
return (yield* InstanceRef) ?? Instance.current
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const workspaceID = Effect.gen(function* () {
|
||||||
|
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
|
||||||
|
})
|
||||||
|
|
||||||
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
||||||
|
|
||||||
export const make = <A, E = never, R = never>(
|
export const make = <A, E = never, R = never>(
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import { Effect, Layer, ManagedRuntime } from "effect"
|
|||||||
import * as ServiceMap from "effect/ServiceMap"
|
import * as ServiceMap from "effect/ServiceMap"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { Context } from "@/util/context"
|
import { Context } from "@/util/context"
|
||||||
import { InstanceRef } from "./instance-ref"
|
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||||
import { Observability } from "./oltp"
|
import { Observability } from "./oltp"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
|
||||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||||
|
|
||||||
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||||
try {
|
try {
|
||||||
const ctx = Instance.current
|
const ctx = Instance.current
|
||||||
return Effect.provideService(effect, InstanceRef, ctx)
|
const workspaceID = WorkspaceContext.workspaceID
|
||||||
|
return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof Context.NotFound)) throw err
|
if (!(err instanceof Context.NotFound)) throw err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
|
|||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { Context } from "../util/context"
|
import { Context } from "../util/context"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
|
|
||||||
export interface InstanceContext {
|
export interface InstanceContext {
|
||||||
@@ -20,19 +21,9 @@ const disposal = {
|
|||||||
all: undefined as Promise<void> | undefined,
|
all: undefined as Promise<void> | undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
function emit(directory: string) {
|
function emitDisposed(directory: string) {}
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory,
|
|
||||||
payload: {
|
|
||||||
type: "server.instance.disposed",
|
|
||||||
properties: {
|
|
||||||
directory,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
|
||||||
return iife(async () => {
|
return iife(async () => {
|
||||||
const ctx =
|
const ctx =
|
||||||
input.project && input.worktree
|
input.project && input.worktree
|
||||||
@@ -93,6 +84,7 @@ export const Instance = {
|
|||||||
get project() {
|
get project() {
|
||||||
return context.use().project
|
return context.use().project
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a path is within the project boundary.
|
* Check if a path is within the project boundary.
|
||||||
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
||||||
@@ -131,15 +123,39 @@ export const Instance = {
|
|||||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||||
cache.delete(directory)
|
cache.delete(directory)
|
||||||
const next = track(directory, boot({ ...input, directory }))
|
const next = track(directory, boot({ ...input, directory }))
|
||||||
emit(directory)
|
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory,
|
||||||
|
project: input.project?.id,
|
||||||
|
workspace: WorkspaceContext.workspaceID,
|
||||||
|
payload: {
|
||||||
|
type: "server.instance.disposed",
|
||||||
|
properties: {
|
||||||
|
directory,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return await next
|
return await next
|
||||||
},
|
},
|
||||||
async dispose() {
|
async dispose() {
|
||||||
const directory = Instance.directory
|
const directory = Instance.directory
|
||||||
|
const project = Instance.project
|
||||||
Log.Default.info("disposing instance", { directory })
|
Log.Default.info("disposing instance", { directory })
|
||||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||||
cache.delete(directory)
|
cache.delete(directory)
|
||||||
emit(directory)
|
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory,
|
||||||
|
project: project.id,
|
||||||
|
workspace: WorkspaceContext.workspaceID,
|
||||||
|
payload: {
|
||||||
|
type: "server.instance.disposed",
|
||||||
|
properties: {
|
||||||
|
directory,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async disposeAll() {
|
async disposeAll() {
|
||||||
if (disposal.all) return disposal.all
|
if (disposal.all) return disposal.all
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export namespace Project {
|
|||||||
const emitUpdated = (data: Info) =>
|
const emitUpdated = (data: Info) =>
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
|
directory: "global",
|
||||||
|
project: data.id,
|
||||||
payload: { type: Event.Updated.type, properties: data },
|
payload: { type: Event.Updated.type, properties: data },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { Filesystem } from "@/util/filesystem"
|
|||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { InstanceRoutes } from "./instance"
|
import { InstanceRoutes } from "./instance"
|
||||||
|
import { Session } from "@/session"
|
||||||
|
import { SessionID } from "@/session/schema"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
|
||||||
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
||||||
|
|
||||||
@@ -26,6 +29,16 @@ function local(method: string, path: string) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getSessionWorkspace(url: URL) {
|
||||||
|
if (url.pathname === "/session/status") return null
|
||||||
|
|
||||||
|
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
|
const session = await Session.get(SessionID.make(id)).catch(() => undefined)
|
||||||
|
return session?.workspaceID
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||||
const routes = lazy(() => InstanceRoutes(upgrade))
|
const routes = lazy(() => InstanceRoutes(upgrade))
|
||||||
|
|
||||||
@@ -42,13 +55,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
)
|
)
|
||||||
|
|
||||||
const url = new URL(c.req.url)
|
const url = new URL(c.req.url)
|
||||||
const workspaceParam = url.searchParams.get("workspace") || c.req.header("x-opencode-workspace")
|
|
||||||
|
|
||||||
// TODO: If session is being routed, force it to lookup the
|
const sessionWorkspaceID = await getSessionWorkspace(url)
|
||||||
// project/workspace
|
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
|
||||||
|
|
||||||
// If no workspace is provided we use the "project" workspace
|
// If no workspace is provided we use the project
|
||||||
if (!workspaceParam) {
|
if (!workspaceID) {
|
||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
init: InstanceBootstrap,
|
init: InstanceBootstrap,
|
||||||
@@ -58,8 +70,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceID = WorkspaceID.make(workspaceParam)
|
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
|
||||||
const workspace = await Workspace.get(workspaceID)
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||||
status: 500,
|
status: 500,
|
||||||
@@ -73,12 +84,16 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
const target = await adaptor.target(workspace)
|
const target = await adaptor.target(workspace)
|
||||||
|
|
||||||
if (target.type === "local") {
|
if (target.type === "local") {
|
||||||
return Instance.provide({
|
return WorkspaceContext.provide({
|
||||||
|
workspaceID: WorkspaceID.make(workspaceID),
|
||||||
|
fn: () =>
|
||||||
|
Instance.provide({
|
||||||
directory: target.directory,
|
directory: target.directory,
|
||||||
init: InstanceBootstrap,
|
init: InstanceBootstrap,
|
||||||
async fn() {
|
async fn() {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return routes().fetch(c.req.raw, c.env)
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
directory: z.string(),
|
directory: z.string(),
|
||||||
|
project: z.string().optional(),
|
||||||
|
workspace: z.string().optional(),
|
||||||
payload: BusEvent.payloads(),
|
payload: BusEvent.payloads(),
|
||||||
})
|
})
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ export namespace Worktree {
|
|||||||
|
|
||||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
|
const workspaceID = yield* InstanceState.workspaceID
|
||||||
const projectID = ctx.project.id
|
const projectID = ctx.project.id
|
||||||
const extra = startCommand?.trim()
|
const extra = startCommand?.trim()
|
||||||
|
|
||||||
@@ -255,6 +256,8 @@ export namespace Worktree {
|
|||||||
log.error("worktree checkout failed", { directory: info.directory, message })
|
log.error("worktree checkout failed", { directory: info.directory, message })
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: info.directory,
|
directory: info.directory,
|
||||||
|
project: ctx.project.id,
|
||||||
|
workspace: workspaceID,
|
||||||
payload: { type: Event.Failed.type, properties: { message } },
|
payload: { type: Event.Failed.type, properties: { message } },
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -272,6 +275,8 @@ export namespace Worktree {
|
|||||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: info.directory,
|
directory: info.directory,
|
||||||
|
project: ctx.project.id,
|
||||||
|
workspace: workspaceID,
|
||||||
payload: { type: Event.Failed.type, properties: { message } },
|
payload: { type: Event.Failed.type, properties: { message } },
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@@ -281,6 +286,8 @@ export namespace Worktree {
|
|||||||
|
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: info.directory,
|
directory: info.directory,
|
||||||
|
project: ctx.project.id,
|
||||||
|
workspace: workspaceID,
|
||||||
payload: {
|
payload: {
|
||||||
type: Event.Ready.type,
|
type: Event.Ready.type,
|
||||||
properties: { name: info.name, branch: info.branch },
|
properties: { name: info.name, branch: info.branch },
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { testRender } from "@opentui/solid"
|
||||||
|
import { onMount } from "solid-js"
|
||||||
|
import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args"
|
||||||
|
import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit"
|
||||||
|
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
|
||||||
|
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||||
|
import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync"
|
||||||
|
|
||||||
|
const sighup = new Set(process.listeners("SIGHUP"))
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const fn of process.listeners("SIGHUP")) {
|
||||||
|
if (!sighup.has(fn)) process.off("SIGHUP", fn)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function json(data: unknown) {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wait(fn: () => boolean, timeout = 2000) {
|
||||||
|
const start = Date.now()
|
||||||
|
while (!fn()) {
|
||||||
|
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||||
|
await Bun.sleep(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function data(workspace?: string | null) {
|
||||||
|
const tag = workspace ?? "root"
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
id: "ses_1",
|
||||||
|
title: `session-${tag}`,
|
||||||
|
workspaceID: workspace ?? undefined,
|
||||||
|
time: {
|
||||||
|
updated: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
info: {
|
||||||
|
id: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
role: "assistant",
|
||||||
|
time: {
|
||||||
|
created: 1,
|
||||||
|
completed: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: "part_1",
|
||||||
|
messageID: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
type: "text",
|
||||||
|
text: `part-${tag}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
todo: [
|
||||||
|
{
|
||||||
|
id: `todo-${tag}`,
|
||||||
|
content: `todo-${tag}`,
|
||||||
|
status: "pending",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diff: [
|
||||||
|
{
|
||||||
|
file: `${tag}.ts`,
|
||||||
|
patch: "",
|
||||||
|
additions: 0,
|
||||||
|
deletions: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hit = {
|
||||||
|
path: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFetch(log: Hit[]) {
|
||||||
|
return Object.assign(
|
||||||
|
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const req = new Request(input, init)
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined
|
||||||
|
log.push({
|
||||||
|
path: url.pathname,
|
||||||
|
workspace,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (url.pathname === "/config/providers") {
|
||||||
|
return json({ providers: [], default: {} })
|
||||||
|
}
|
||||||
|
if (url.pathname === "/provider") {
|
||||||
|
return json({ all: [], default: {}, connected: [] })
|
||||||
|
}
|
||||||
|
if (url.pathname === "/experimental/console") {
|
||||||
|
return json({})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/agent") {
|
||||||
|
return json([])
|
||||||
|
}
|
||||||
|
if (url.pathname === "/config") {
|
||||||
|
return json({})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/project/current") {
|
||||||
|
return json({ id: `proj-${workspace ?? "root"}` })
|
||||||
|
}
|
||||||
|
if (url.pathname === "/path") {
|
||||||
|
return json({
|
||||||
|
state: `/tmp/${workspace ?? "root"}/state`,
|
||||||
|
config: `/tmp/${workspace ?? "root"}/config`,
|
||||||
|
worktree: "/tmp/worktree",
|
||||||
|
directory: `/tmp/${workspace ?? "root"}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/session") {
|
||||||
|
return json([])
|
||||||
|
}
|
||||||
|
if (url.pathname === "/command") {
|
||||||
|
return json([])
|
||||||
|
}
|
||||||
|
if (url.pathname === "/lsp") {
|
||||||
|
return json([])
|
||||||
|
}
|
||||||
|
if (url.pathname === "/mcp") {
|
||||||
|
return json({})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/experimental/resource") {
|
||||||
|
return json({})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/formatter") {
|
||||||
|
return json([])
|
||||||
|
}
|
||||||
|
if (url.pathname === "/session/status") {
|
||||||
|
return json({})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/provider/auth") {
|
||||||
|
return json({})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/vcs") {
|
||||||
|
return json({ branch: "main" })
|
||||||
|
}
|
||||||
|
if (url.pathname === "/experimental/workspace") {
|
||||||
|
return json([{ id: "ws_a" }, { id: "ws_b" }])
|
||||||
|
}
|
||||||
|
if (url.pathname === "/session/ses_1") {
|
||||||
|
return json(data(workspace).session)
|
||||||
|
}
|
||||||
|
if (url.pathname === "/session/ses_1/message") {
|
||||||
|
return json([data(workspace).message])
|
||||||
|
}
|
||||||
|
if (url.pathname === "/session/ses_1/todo") {
|
||||||
|
return json(data(workspace).todo)
|
||||||
|
}
|
||||||
|
if (url.pathname === "/session/ses_1/diff") {
|
||||||
|
return json(data(workspace).diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`unexpected request: ${req.method} ${url.pathname}`)
|
||||||
|
},
|
||||||
|
{ preconnect: fetch.preconnect.bind(fetch) },
|
||||||
|
) satisfies typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mount(log: Hit[]) {
|
||||||
|
let project!: ReturnType<typeof useProject>
|
||||||
|
let sync!: ReturnType<typeof useSync>
|
||||||
|
let done!: () => void
|
||||||
|
const ready = new Promise<void>((resolve) => {
|
||||||
|
done = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<SDKProvider
|
||||||
|
url="http://test"
|
||||||
|
directory="/tmp/root"
|
||||||
|
fetch={createFetch(log)}
|
||||||
|
events={{ subscribe: async () => () => {} }}
|
||||||
|
>
|
||||||
|
<ArgsProvider continue={false}>
|
||||||
|
<ExitProvider>
|
||||||
|
<ProjectProvider>
|
||||||
|
<SyncProvider>
|
||||||
|
<Probe
|
||||||
|
onReady={(ctx) => {
|
||||||
|
project = ctx.project
|
||||||
|
sync = ctx.sync
|
||||||
|
done()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SyncProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</ExitProvider>
|
||||||
|
</ArgsProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
))
|
||||||
|
|
||||||
|
await ready
|
||||||
|
return { app, project, sync }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitBoot(log: Hit[], workspace?: string) {
|
||||||
|
await wait(() => log.some((item) => item.path === "/experimental/workspace"))
|
||||||
|
if (!workspace) return
|
||||||
|
await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Probe(props: {
|
||||||
|
onReady: (ctx: { project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }) => void
|
||||||
|
}) {
|
||||||
|
const project = useProject()
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
props.onReady({ project, sync })
|
||||||
|
})
|
||||||
|
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SyncProvider", () => {
|
||||||
|
test("re-runs bootstrap requests when the active workspace changes", async () => {
|
||||||
|
const log: Hit[] = []
|
||||||
|
const { app, project } = await mount(log)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitBoot(log)
|
||||||
|
log.length = 0
|
||||||
|
|
||||||
|
project.workspace.set("ws_a")
|
||||||
|
|
||||||
|
await waitBoot(log, "ws_a")
|
||||||
|
|
||||||
|
expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true)
|
||||||
|
expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true)
|
||||||
|
expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true)
|
||||||
|
expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears full-sync cache when the active workspace changes", async () => {
|
||||||
|
const log: Hit[] = []
|
||||||
|
const { app, project, sync } = await mount(log)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitBoot(log)
|
||||||
|
|
||||||
|
log.length = 0
|
||||||
|
project.workspace.set("ws_a")
|
||||||
|
await waitBoot(log, "ws_a")
|
||||||
|
expect(project.workspace.current()).toBe("ws_a")
|
||||||
|
|
||||||
|
log.length = 0
|
||||||
|
await sync.session.sync("ses_1")
|
||||||
|
|
||||||
|
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1)
|
||||||
|
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a")
|
||||||
|
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
|
||||||
|
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" })
|
||||||
|
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts")
|
||||||
|
|
||||||
|
log.length = 0
|
||||||
|
project.workspace.set("ws_b")
|
||||||
|
await waitBoot(log, "ws_b")
|
||||||
|
expect(project.workspace.current()).toBe("ws_b")
|
||||||
|
|
||||||
|
log.length = 0
|
||||||
|
await sync.session.sync("ses_1")
|
||||||
|
await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b"))
|
||||||
|
|
||||||
|
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1)
|
||||||
|
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b")
|
||||||
|
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
|
||||||
|
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" })
|
||||||
|
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts")
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { testRender } from "@opentui/solid"
|
||||||
|
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
|
import { onMount } from "solid-js"
|
||||||
|
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
|
||||||
|
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||||
|
import { useEvent } from "../../../src/cli/cmd/tui/context/event"
|
||||||
|
|
||||||
|
async function wait(fn: () => boolean, timeout = 2000) {
|
||||||
|
const start = Date.now()
|
||||||
|
while (!fn()) {
|
||||||
|
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||||
|
await Bun.sleep(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent {
|
||||||
|
return {
|
||||||
|
directory: input.directory,
|
||||||
|
workspace: input.workspace,
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function vcs(branch: string): Event {
|
||||||
|
return {
|
||||||
|
type: "vcs.branch.updated",
|
||||||
|
properties: {
|
||||||
|
branch,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(version: string): Event {
|
||||||
|
return {
|
||||||
|
type: "installation.update-available",
|
||||||
|
properties: {
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSource() {
|
||||||
|
let fn: ((event: GlobalEvent) => void) | undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: {
|
||||||
|
subscribe: async (handler: (event: GlobalEvent) => void) => {
|
||||||
|
fn = handler
|
||||||
|
return () => {
|
||||||
|
if (fn === handler) fn = undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emit(evt: GlobalEvent) {
|
||||||
|
if (!fn) throw new Error("event source not ready")
|
||||||
|
fn(evt)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mount() {
|
||||||
|
const source = createSource()
|
||||||
|
const seen: Event[] = []
|
||||||
|
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}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<Probe
|
||||||
|
onReady={(ctx) => {
|
||||||
|
project = ctx.project
|
||||||
|
done()
|
||||||
|
}}
|
||||||
|
seen={seen}
|
||||||
|
/>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
))
|
||||||
|
|
||||||
|
await ready
|
||||||
|
return { app, emit: source.emit, project, seen }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<typeof useProject> }) => void }) {
|
||||||
|
const project = useProject()
|
||||||
|
const event = useEvent()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
event.subscribe((evt) => {
|
||||||
|
props.seen.push(evt)
|
||||||
|
})
|
||||||
|
props.onReady({ project })
|
||||||
|
})
|
||||||
|
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useEvent", () => {
|
||||||
|
test("delivers matching directory events without an active workspace", async () => {
|
||||||
|
const { app, emit, seen } = await mount()
|
||||||
|
|
||||||
|
try {
|
||||||
|
emit(event(vcs("main"), { directory: "/tmp/root" }))
|
||||||
|
|
||||||
|
await wait(() => seen.length === 1)
|
||||||
|
|
||||||
|
expect(seen).toEqual([vcs("main")])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores non-matching directory events without an active workspace", async () => {
|
||||||
|
const { app, emit, seen } = await mount()
|
||||||
|
|
||||||
|
try {
|
||||||
|
emit(event(vcs("other"), { directory: "/tmp/other" }))
|
||||||
|
await Bun.sleep(30)
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(0)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("delivers 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/other", workspace: "ws_a" }))
|
||||||
|
|
||||||
|
await wait(() => seen.length === 1)
|
||||||
|
|
||||||
|
expect(seen).toEqual([vcs("ws")])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
try {
|
||||||
|
project.workspace.set("ws_a")
|
||||||
|
emit(event(update("1.2.3"), { directory: "global" }))
|
||||||
|
|
||||||
|
await wait(() => seen.length === 1)
|
||||||
|
|
||||||
|
expect(seen).toEqual([update("1.2.3")])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1011,6 +1011,8 @@ export type Event =
|
|||||||
|
|
||||||
export type GlobalEvent = {
|
export type GlobalEvent = {
|
||||||
directory: string
|
directory: string
|
||||||
|
project?: string
|
||||||
|
workspace?: string
|
||||||
payload: Event
|
payload: Event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9926,6 +9926,12 @@
|
|||||||
"directory": {
|
"directory": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"project": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"payload": {
|
"payload": {
|
||||||
"$ref": "#/components/schemas/Event"
|
"$ref": "#/components/schemas/Event"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user