mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-21 05:10:58 +08:00
rector(core,tui): handle workspace state in project context, add workspace status, improve ui (#21896)
This commit is contained in:
@@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
|
||||
- `api.kv.get`, `set`, `ready`
|
||||
- `api.state`
|
||||
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
|
||||
- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
|
||||
- `api.client`
|
||||
- `api.event.on(type, handler)`
|
||||
- `api.renderer`
|
||||
- `api.slots.register(plugin)`
|
||||
@@ -270,7 +270,6 @@ Command behavior:
|
||||
- `provider`
|
||||
- `path.{state,config,worktree,directory}`
|
||||
- `vcs?.branch`
|
||||
- `workspace.list()` / `workspace.get(workspaceID)`
|
||||
- `session.count()`
|
||||
- `session.diff(sessionID)`
|
||||
- `session.todo(sessionID)`
|
||||
@@ -282,8 +281,6 @@ Command behavior:
|
||||
- `lsp()`
|
||||
- `mcp()`
|
||||
- `api.client` always reflects the current runtime client.
|
||||
- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
|
||||
- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
|
||||
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
|
||||
- `api.renderer` exposes the raw `CliRenderer`.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { ErrorComponent } from "@tui/component/error-component"
|
||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||
import { ProjectProvider } from "@tui/context/project"
|
||||
import { ProjectProvider, useProject } from "@tui/context/project"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
@@ -36,7 +36,6 @@ import { DialogHelp } from "./ui/dialog-help"
|
||||
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
|
||||
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
@@ -465,22 +464,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
},
|
||||
},
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
? [
|
||||
{
|
||||
title: "Manage workspaces",
|
||||
value: "workspace.list",
|
||||
category: "Workspace",
|
||||
suggested: true,
|
||||
slash: {
|
||||
name: "workspaces",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogWorkspaceList />)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "New session",
|
||||
suggested: route.data.type === "session",
|
||||
|
||||
@@ -2,25 +2,31 @@ import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { useKV } from "../context/kv"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
|
||||
@@ -31,15 +37,68 @@ export function DialogSessionList() {
|
||||
})
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||
|
||||
function createWorkspace() {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(workspaceID) =>
|
||||
openWorkspaceSession({
|
||||
dialog,
|
||||
route,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
workspaceID,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => {
|
||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||
|
||||
let workspaceStatus: WorkspaceStatus | null = null
|
||||
if (x.workspaceID) {
|
||||
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
|
||||
}
|
||||
|
||||
let footer = ""
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
if (x.workspaceID) {
|
||||
let desc = "unknown"
|
||||
if (workspace) {
|
||||
desc = `${workspace.type}: ${workspace.name}`
|
||||
}
|
||||
|
||||
footer = (
|
||||
<>
|
||||
{desc}{" "}
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
workspaceStatus === "error"
|
||||
? theme.error
|
||||
: workspaceStatus === "disconnected"
|
||||
? theme.textMuted
|
||||
: theme.success,
|
||||
}}
|
||||
>
|
||||
■
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
footer = Locale.time(x.time.updated)
|
||||
}
|
||||
|
||||
const date = new Date(x.time.updated)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
@@ -53,7 +112,7 @@ export function DialogSessionList() {
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
footer,
|
||||
gutter: isWorking ? <Spinner /> : undefined,
|
||||
}
|
||||
})
|
||||
@@ -102,6 +161,15 @@ export function DialogSessionList() {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+w")[0],
|
||||
title: "new workspace",
|
||||
side: "right",
|
||||
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
onTrigger: () => {
|
||||
createWorkspace()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
export async function openWorkspaceSession(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string
|
||||
}) {
|
||||
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||
while (true) {
|
||||
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
|
||||
if (!result) {
|
||||
input.toast.show({
|
||||
message: "Failed to create workspace session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.response.status >= 500 && result.response.status < 600) {
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
input.toast.show({
|
||||
message: "Failed to create workspace session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: result.data.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const type = creating()
|
||||
if (type) {
|
||||
return [
|
||||
{
|
||||
title: `Creating ${type} workspace...`,
|
||||
value: "creating" as const,
|
||||
description: "This can take a while for remote environments",
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: "Worktree",
|
||||
value: "worktree" as const,
|
||||
description: "Create a local git worktree",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const create = async (type: string) => {
|
||||
if (creating()) return
|
||||
setCreating(type)
|
||||
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
setCreating(undefined)
|
||||
toast.show({
|
||||
message: "Failed to create workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await props.onSelect(workspace.id)
|
||||
setCreating(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value === "creating") return
|
||||
void create(option.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
async function openWorkspace(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string
|
||||
forceCreate?: boolean
|
||||
}) {
|
||||
const cacheSession = (session: Session) => {
|
||||
input.sync.set(
|
||||
"session",
|
||||
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
|
||||
const session = listed?.data?.[0]
|
||||
if (session?.id) {
|
||||
cacheSession(session)
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: session.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
return
|
||||
}
|
||||
let created: Session | undefined
|
||||
while (!created) {
|
||||
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
|
||||
if (!result) {
|
||||
input.toast.show({
|
||||
message: "Failed to open workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.response.status >= 500 && result.response.status < 600) {
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
input.toast.show({
|
||||
message: "Failed to open workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
created = result.data
|
||||
}
|
||||
cacheSession(created)
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: created.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
}
|
||||
|
||||
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const type = creating()
|
||||
if (type) {
|
||||
return [
|
||||
{
|
||||
title: `Creating ${type} workspace...`,
|
||||
value: "creating" as const,
|
||||
description: "This can take a while for remote environments",
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: "Worktree",
|
||||
value: "worktree" as const,
|
||||
description: "Create a local git worktree",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const createWorkspace = async (type: string) => {
|
||||
if (creating()) return
|
||||
setCreating(type)
|
||||
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
|
||||
console.log(err)
|
||||
return undefined
|
||||
})
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
setCreating(undefined)
|
||||
toast.show({
|
||||
message: "Failed to create workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
await sync.workspace.sync()
|
||||
await props.onSelect(workspace.id)
|
||||
setCreating(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value === "creating") return
|
||||
void createWorkspace(option.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const project = useProject()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const keybind = useKeybind()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
|
||||
|
||||
const open = (workspaceID: string, forceCreate?: boolean) =>
|
||||
openWorkspace({
|
||||
dialog,
|
||||
route,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
workspaceID,
|
||||
forceCreate,
|
||||
})
|
||||
|
||||
async function selectWorkspace(workspaceID: string | null) {
|
||||
if (workspaceID == null) {
|
||||
project.workspace.set(undefined)
|
||||
if (localCount() > 0) {
|
||||
dialog.replace(() => <DialogSessionList localOnly={true} />)
|
||||
return
|
||||
}
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const count = counts()[workspaceID]
|
||||
if (count && count > 0) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
return
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await open(workspaceID)
|
||||
return
|
||||
}
|
||||
const client = scoped(sdk, sync, workspaceID)
|
||||
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
|
||||
if (listed?.data?.length) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
return
|
||||
}
|
||||
await open(workspaceID)
|
||||
}
|
||||
|
||||
const currentWorkspaceID = createMemo(() => project.workspace.current())
|
||||
|
||||
const localCount = createMemo(
|
||||
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
|
||||
)
|
||||
|
||||
let run = 0
|
||||
createEffect(() => {
|
||||
const workspaces = sync.data.workspaceList
|
||||
const next = ++run
|
||||
if (!workspaces.length) {
|
||||
setCounts({})
|
||||
return
|
||||
}
|
||||
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
|
||||
void Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const client = scoped(sdk, sync, workspace.id)
|
||||
const result = await client.session.list({ roots: true }).catch(() => undefined)
|
||||
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
|
||||
}),
|
||||
).then((entries) => {
|
||||
if (run !== next) return
|
||||
setCounts(Object.fromEntries(entries))
|
||||
})
|
||||
})
|
||||
|
||||
const options = createMemo(() => [
|
||||
{
|
||||
title: "Local",
|
||||
value: null,
|
||||
category: "Workspace",
|
||||
description: "Use the local machine",
|
||||
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
|
||||
},
|
||||
...sync.data.workspaceList.map((workspace) => {
|
||||
const count = counts()[workspace.id]
|
||||
return {
|
||||
title:
|
||||
toDelete() === workspace.id
|
||||
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
|
||||
: workspace.id,
|
||||
value: workspace.id,
|
||||
category: workspace.type,
|
||||
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
|
||||
footer:
|
||||
count === undefined
|
||||
? "Loading sessions..."
|
||||
: count === null
|
||||
? "Sessions unavailable"
|
||||
: `${count} session${count === 1 ? "" : "s"}`,
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: "+ New workspace",
|
||||
value: "__create__",
|
||||
category: "Actions",
|
||||
description: "Create a new workspace",
|
||||
},
|
||||
])
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
void sync.workspace.sync()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Workspaces"
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
current={currentWorkspaceID()}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
setToDelete(undefined)
|
||||
if (option.value === "__create__") {
|
||||
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
|
||||
return
|
||||
}
|
||||
void selectWorkspace(option.value)
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (option.value === "__create__" || option.value === null) return
|
||||
if (toDelete() !== option.value) {
|
||||
setToDelete(option.value)
|
||||
return
|
||||
}
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
|
||||
setToDelete(undefined)
|
||||
if (result?.error) {
|
||||
toast.show({
|
||||
message: "Failed to delete workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (currentWorkspaceID() === option.value) {
|
||||
project.workspace.set(undefined)
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
}
|
||||
await sync.workspace.sync()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { DialogSessionRename } from "../dialog-session-rename"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { Spinner } from "../spinner"
|
||||
import { useToast } from "../../ui/toast"
|
||||
|
||||
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
|
||||
const [listed, listedActions] = createResource(
|
||||
() => props.workspaceID,
|
||||
async (workspaceID) => {
|
||||
if (!workspaceID) return undefined
|
||||
const result = await sdk.client.session.list({ roots: true })
|
||||
return result.data ?? []
|
||||
},
|
||||
)
|
||||
|
||||
const [searchResults] = createResource(search, async (query) => {
|
||||
if (!query || props.localOnly) return undefined
|
||||
const result = await sdk.client.session.list({
|
||||
search: query,
|
||||
limit: 30,
|
||||
...(props.workspaceID ? { roots: true } : {}),
|
||||
})
|
||||
return result.data ?? []
|
||||
})
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
||||
const sessions = createMemo(() => {
|
||||
if (searchResults()) return searchResults()!
|
||||
if (props.workspaceID) return listed() ?? []
|
||||
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
|
||||
return sync.data.session
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sessions()
|
||||
.filter((x) => {
|
||||
if (x.parentID !== undefined) return false
|
||||
if (props.workspaceID && listed()) return true
|
||||
if (props.workspaceID) return x.workspaceID === props.workspaceID
|
||||
if (props.localOnly) return !x.workspaceID
|
||||
return true
|
||||
})
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => {
|
||||
const date = new Date(x.time.updated)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
category = "Today"
|
||||
}
|
||||
const isDeleting = toDelete() === x.id
|
||||
const status = sync.data.session_status?.[x.id]
|
||||
const isWorking = status?.type === "busy"
|
||||
return {
|
||||
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
gutter: isWorking ? <Spinner /> : undefined,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
|
||||
options={options()}
|
||||
skipFilter={!props.localOnly}
|
||||
current={currentSessionID()}
|
||||
onFilter={setSearch}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
const deleted = await sdk.client.session
|
||||
.delete({
|
||||
sessionID: option.value,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
setToDelete(undefined)
|
||||
if (!deleted) {
|
||||
toast.show({
|
||||
message: "Failed to delete session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (props.workspaceID) {
|
||||
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
|
||||
return
|
||||
}
|
||||
sync.set(
|
||||
"session",
|
||||
sync.data.session.filter((session) => session.id !== option.value),
|
||||
)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.session_rename?.[0],
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { batch } from "solid-js"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
|
||||
name: "Project",
|
||||
init: () => {
|
||||
@@ -14,17 +16,22 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
},
|
||||
instance: {
|
||||
path: {
|
||||
home: "",
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: "",
|
||||
directory: sdk.directory ?? "",
|
||||
} satisfies Path,
|
||||
},
|
||||
workspace: undefined as string | undefined,
|
||||
workspace: {
|
||||
current: undefined as string | undefined,
|
||||
list: [] as Workspace[],
|
||||
status: {} as Record<string, WorkspaceStatus>,
|
||||
},
|
||||
})
|
||||
|
||||
async function sync() {
|
||||
const workspace = store.workspace
|
||||
const workspace = store.workspace.current
|
||||
const [path, project] = await Promise.all([
|
||||
sdk.client.path.get({ workspace }),
|
||||
sdk.client.project.current({ workspace }),
|
||||
@@ -36,6 +43,27 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
})
|
||||
}
|
||||
|
||||
async function syncWorkspace() {
|
||||
const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
if (!listed?.data) return
|
||||
const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
|
||||
const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
|
||||
|
||||
batch(() => {
|
||||
setStore("workspace", "list", reconcile(listed.data))
|
||||
setStore("workspace", "status", reconcile(next))
|
||||
if (!listed.data.some((item) => item.id === store.workspace.current)) {
|
||||
setStore("workspace", "current", undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sdk.event.on("event", (event) => {
|
||||
if (event.payload.type === "workspace.status") {
|
||||
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
data: store,
|
||||
project() {
|
||||
@@ -51,13 +79,26 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
},
|
||||
workspace: {
|
||||
current() {
|
||||
return store.workspace
|
||||
return store.workspace.current
|
||||
},
|
||||
set(next?: string | null) {
|
||||
const workspace = next ?? undefined
|
||||
if (store.workspace === workspace) return
|
||||
setStore("workspace", workspace)
|
||||
if (store.workspace.current === workspace) return
|
||||
setStore("workspace", "current", workspace)
|
||||
},
|
||||
list() {
|
||||
return store.workspace.list
|
||||
},
|
||||
get(workspaceID: string) {
|
||||
return store.workspace.list.find((item) => item.id === workspaceID)
|
||||
},
|
||||
status(workspaceID: string) {
|
||||
return store.workspace.status[workspaceID]
|
||||
},
|
||||
statuses() {
|
||||
return store.workspace.status
|
||||
},
|
||||
sync: syncWorkspace,
|
||||
},
|
||||
sync,
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
ProviderListResponse,
|
||||
ProviderAuthMethod,
|
||||
VcsInfo,
|
||||
Workspace,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useProject } from "@tui/context/project"
|
||||
@@ -75,7 +74,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
[key: string]: McpResource
|
||||
}
|
||||
formatter: FormatterStatus[]
|
||||
workspaceList: Workspace[]
|
||||
vcs: VcsInfo | undefined
|
||||
}>({
|
||||
provider_next: {
|
||||
@@ -103,7 +101,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
formatter: [],
|
||||
workspaceList: [],
|
||||
vcs: undefined,
|
||||
})
|
||||
|
||||
@@ -111,16 +108,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const workspace = project.workspace.current()
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
if (!result?.data) return
|
||||
setStore("workspaceList", reconcile(result.data))
|
||||
if (!result.data.some((item) => item.id === workspace)) {
|
||||
project.workspace.set(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed":
|
||||
@@ -368,7 +355,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const workspace = project.workspace.current()
|
||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const sessionListPromise = sdk.client.session
|
||||
.list({ start: start, workspace })
|
||||
.list({ start: start })
|
||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||
|
||||
// blocking - include session.list when continuing a session
|
||||
@@ -443,7 +430,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}),
|
||||
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
|
||||
syncWorkspaces(),
|
||||
project.workspace.sync(),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
@@ -522,15 +509,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
fullSyncedSessions.add(sessionID)
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
list() {
|
||||
return store.workspaceList
|
||||
},
|
||||
get(workspaceID: string) {
|
||||
return store.workspaceList.find((item) => item.id === workspaceID)
|
||||
},
|
||||
sync: syncWorkspaces,
|
||||
},
|
||||
bootstrap,
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -146,14 +146,6 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
branch: sync.data.vcs.branch,
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
list() {
|
||||
return sync.data.workspaceList
|
||||
},
|
||||
get(workspaceID) {
|
||||
return sync.workspace.get(workspaceID)
|
||||
},
|
||||
},
|
||||
session: {
|
||||
count() {
|
||||
return sync.data.session.length
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
|
||||
keybind?: {
|
||||
keybind?: Keybind.Info
|
||||
title: string
|
||||
side?: "left" | "right"
|
||||
disabled?: boolean
|
||||
onTrigger: (option: DialogSelectOption<T>) => void
|
||||
}[]
|
||||
@@ -42,6 +43,7 @@ export interface DialogSelectOption<T = any> {
|
||||
disabled?: boolean
|
||||
bg?: RGBA
|
||||
gutter?: JSX.Element
|
||||
margin?: JSX.Element
|
||||
onSelect?: (ctx: DialogContext) => void
|
||||
}
|
||||
|
||||
@@ -234,6 +236,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
props.ref?.(ref)
|
||||
|
||||
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
|
||||
const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
|
||||
const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
|
||||
|
||||
return (
|
||||
<box gap={1} paddingBottom={1}>
|
||||
@@ -312,6 +316,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
position="relative"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
}}
|
||||
@@ -335,6 +340,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
>
|
||||
<Show when={!current() && option.margin}>
|
||||
<box position="absolute" left={1} flexShrink={0}>
|
||||
{option.margin}
|
||||
</box>
|
||||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
@@ -353,17 +363,38 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
</scrollbox>
|
||||
</Show>
|
||||
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
|
||||
<box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
|
||||
<For each={keybinds()}>
|
||||
{(item) => (
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>{item.title}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<box
|
||||
paddingRight={2}
|
||||
paddingLeft={4}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<For each={left()}>
|
||||
{(item) => (
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>{item.title}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<For each={right()}>
|
||||
{(item) => (
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>{item.title}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -5,7 +5,9 @@ import { Database, eq } from "@/storage/db"
|
||||
import { Project } from "@/project/project"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Log } from "@/util/log"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { WorkspaceTable } from "./workspace.sql"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
@@ -14,6 +16,18 @@ import { WorkspaceID } from "./schema"
|
||||
import { parseSSE } from "./sse"
|
||||
|
||||
export namespace Workspace {
|
||||
export const Info = WorkspaceInfo.meta({
|
||||
ref: "Workspace",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const ConnectionStatus = z.object({
|
||||
workspaceID: WorkspaceID.zod,
|
||||
status: z.enum(["connected", "connecting", "disconnected", "error"]),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
|
||||
|
||||
export const Event = {
|
||||
Ready: BusEvent.define(
|
||||
"workspace.ready",
|
||||
@@ -27,13 +41,9 @@ export namespace Workspace {
|
||||
message: z.string(),
|
||||
}),
|
||||
),
|
||||
Status: BusEvent.define("workspace.status", ConnectionStatus),
|
||||
}
|
||||
|
||||
export const Info = WorkspaceInfo.meta({
|
||||
ref: "Workspace",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -85,6 +95,9 @@ export namespace Workspace {
|
||||
})
|
||||
|
||||
await adaptor.create(config)
|
||||
|
||||
startSync(info)
|
||||
|
||||
return info
|
||||
})
|
||||
|
||||
@@ -92,18 +105,24 @@ export namespace Workspace {
|
||||
const rows = Database.use((db) =>
|
||||
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
|
||||
)
|
||||
return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
|
||||
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
|
||||
for (const space of spaces) startSync(space)
|
||||
return spaces
|
||||
}
|
||||
|
||||
export const get = fn(WorkspaceID.zod, async (id) => {
|
||||
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||
if (!row) return
|
||||
return fromRow(row)
|
||||
const space = fromRow(row)
|
||||
startSync(space)
|
||||
return space
|
||||
})
|
||||
|
||||
export const remove = fn(WorkspaceID.zod, async (id) => {
|
||||
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||
if (row) {
|
||||
stopSync(id)
|
||||
|
||||
const info = fromRow(row)
|
||||
const adaptor = await getAdaptor(row.type)
|
||||
adaptor.remove(info)
|
||||
@@ -111,58 +130,100 @@ export namespace Workspace {
|
||||
return info
|
||||
}
|
||||
})
|
||||
|
||||
const connections = new Map<WorkspaceID, ConnectionStatus>()
|
||||
const aborts = new Map<WorkspaceID, AbortController>()
|
||||
|
||||
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
|
||||
const prev = connections.get(id)
|
||||
if (prev?.status === status && prev?.error === error) return
|
||||
const next = { workspaceID: id, status, error }
|
||||
connections.set(id, next)
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
workspace: id,
|
||||
payload: {
|
||||
type: Event.Status.type,
|
||||
properties: next,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function status(): ConnectionStatus[] {
|
||||
return [...connections.values()]
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "workspace-sync" })
|
||||
|
||||
async function workspaceEventLoop(space: Info, stop: AbortSignal) {
|
||||
while (!stop.aborted) {
|
||||
async function workspaceEventLoop(space: Info, signal: AbortSignal) {
|
||||
log.info("starting sync: " + space.id)
|
||||
|
||||
while (!signal.aborted) {
|
||||
log.info("connecting to sync: " + space.id)
|
||||
|
||||
setStatus(space.id, "connecting")
|
||||
const adaptor = await getAdaptor(space.type)
|
||||
const target = await Promise.resolve(adaptor.target(space))
|
||||
const target = await adaptor.target(space)
|
||||
|
||||
if (target.type === "local") {
|
||||
return
|
||||
}
|
||||
if (target.type === "local") return
|
||||
|
||||
const baseURL = String(target.url).replace(/\/?$/, "/")
|
||||
|
||||
const res = await fetch(new URL(baseURL + "/event"), {
|
||||
method: "GET",
|
||||
signal: stop,
|
||||
const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => {
|
||||
setStatus(space.id, "error", String(err))
|
||||
return undefined
|
||||
})
|
||||
if (!res || !res.ok || !res.body) {
|
||||
log.info("failed to connect to sync: " + res?.status)
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response")
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
setStatus(space.id, "connected")
|
||||
await parseSSE(res.body, signal, (evt) => {
|
||||
const event = evt as SyncEvent.SerializedEvent
|
||||
|
||||
// await parseSSE(res.body, stop, (event) => {
|
||||
// GlobalBus.emit("event", {
|
||||
// directory: space.id,
|
||||
// payload: event,
|
||||
// })
|
||||
// })
|
||||
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
try {
|
||||
if (!event.type.startsWith("server.")) {
|
||||
SyncEvent.replay(event)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("failed to replay sync event", {
|
||||
workspaceID: space.id,
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
})
|
||||
setStatus(space.id, "disconnected")
|
||||
log.info("disconnected to sync: " + space.id)
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
|
||||
export function startSyncing(project: Project.Info) {
|
||||
const stop = new AbortController()
|
||||
const spaces = list(project).filter((space) => space.type !== "worktree")
|
||||
function startSync(space: Info) {
|
||||
if (space.type === "worktree") {
|
||||
void Filesystem.exists(space.directory!).then((exists) => {
|
||||
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
spaces.forEach((space) => {
|
||||
void workspaceEventLoop(space, stop.signal).catch((error) => {
|
||||
log.warn("workspace sync listener failed", {
|
||||
workspaceID: space.id,
|
||||
error,
|
||||
})
|
||||
if (aborts.has(space.id)) return
|
||||
const abort = new AbortController()
|
||||
aborts.set(space.id, abort)
|
||||
setStatus(space.id, "disconnected")
|
||||
|
||||
void workspaceEventLoop(space, abort.signal).catch((error) => {
|
||||
setStatus(space.id, "error", String(error))
|
||||
log.warn("workspace sync listener failed", {
|
||||
workspaceID: space.id,
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
async stop() {
|
||||
stop.abort()
|
||||
},
|
||||
}
|
||||
function stopSync(id: WorkspaceID) {
|
||||
aborts.get(id)?.abort()
|
||||
aborts.delete(id)
|
||||
connections.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,13 +29,20 @@ function local(method: string, path: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
async function getSessionWorkspace(url: URL) {
|
||||
function getSessionID(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 SessionID.make(id)
|
||||
}
|
||||
|
||||
async function getSessionWorkspace(url: URL) {
|
||||
const id = getSessionID(url)
|
||||
if (!id) return null
|
||||
|
||||
const session = await Session.get(id).catch(() => undefined)
|
||||
return session?.workspaceID
|
||||
}
|
||||
|
||||
@@ -71,7 +78,18 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
}
|
||||
|
||||
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
|
||||
|
||||
if (!workspace) {
|
||||
// Special-case deleting a session in case user's data in a
|
||||
// weird state. Allow them to forcefully delete a synced session
|
||||
// even if the remote workspace is not in their data.
|
||||
//
|
||||
// The lets the `DELETE /session/:id` endpoint through and we've
|
||||
// made sure that it will run without an instance
|
||||
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
}
|
||||
|
||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||
status: 500,
|
||||
headers: {
|
||||
|
||||
@@ -62,6 +62,28 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
return c.json(Workspace.list(Instance.project))
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/status",
|
||||
describeRoute({
|
||||
summary: "Workspace status",
|
||||
description: "Get connection status for workspaces in the current project.",
|
||||
operationId: "experimental.workspace.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(Workspace.ConnectionStatus)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const ids = new Set(Workspace.list(Instance.project).map((item) => item.id))
|
||||
return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID)))
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
|
||||
@@ -413,26 +413,35 @@ export namespace Session {
|
||||
})
|
||||
|
||||
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const rows = yield* db((d) =>
|
||||
d
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID)))
|
||||
.where(and(eq(SessionTable.parent_id, parentID)))
|
||||
.all(),
|
||||
)
|
||||
return rows.map(fromRow)
|
||||
})
|
||||
|
||||
const remove: (sessionID: SessionID) => Effect.Effect<void> = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
try {
|
||||
const session = yield* get(sessionID)
|
||||
const kids = yield* children(sessionID)
|
||||
for (const child of kids) {
|
||||
yield* remove(child.id)
|
||||
}
|
||||
|
||||
// `remove` needs to work in all cases, such as a broken
|
||||
// sessions that run cleanup. In certain cases these will
|
||||
// run without any instance state, so we need to turn off
|
||||
// publishing of events in that case
|
||||
const hasInstance = yield* InstanceState.directory.pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchCause(() => Effect.succeed(false)),
|
||||
)
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
SyncEvent.run(Event.Deleted, { sessionID, info: session })
|
||||
SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
|
||||
SyncEvent.remove(sessionID)
|
||||
})
|
||||
} catch (e) {
|
||||
|
||||
@@ -165,7 +165,7 @@ export namespace SyncEvent {
|
||||
// and it validets all the sequence ids
|
||||
// * when loading events from db, apply zod validation to ensure shape
|
||||
|
||||
export function replay(event: SerializedEvent, options?: { republish: boolean }) {
|
||||
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
|
||||
const def = registry.get(event.type)
|
||||
if (!def) {
|
||||
throw new Error(`Unknown event type: ${event.type}`)
|
||||
@@ -189,10 +189,10 @@ export namespace SyncEvent {
|
||||
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
|
||||
}
|
||||
|
||||
process(def, event, { publish: !!options?.republish })
|
||||
process(def, event, { publish: !!options?.publish })
|
||||
}
|
||||
|
||||
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"]) {
|
||||
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
|
||||
const agg = (data as Record<string, string>)[def.aggregate]
|
||||
// This should never happen: we've enforced it via typescript in
|
||||
// the definition
|
||||
@@ -204,6 +204,8 @@ export namespace SyncEvent {
|
||||
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
|
||||
}
|
||||
|
||||
const { publish = true } = options || {}
|
||||
|
||||
// Note that this is an "immediate" transaction which is critical.
|
||||
// We need to make sure we can safely read and write with nothing
|
||||
// else changing the data from under us
|
||||
@@ -218,7 +220,7 @@ export namespace SyncEvent {
|
||||
const seq = row?.seq != null ? row.seq + 1 : 0
|
||||
|
||||
const event = { id, seq, aggregateID: agg, data }
|
||||
process(def, event, { publish: true })
|
||||
process(def, event, { publish })
|
||||
},
|
||||
{
|
||||
behavior: "immediate",
|
||||
|
||||
@@ -244,7 +244,6 @@ describe("SyncProvider", () => {
|
||||
|
||||
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()
|
||||
|
||||
@@ -93,7 +93,6 @@ type Opts = {
|
||||
provider?: HostPluginApi["state"]["provider"]
|
||||
path?: HostPluginApi["state"]["path"]
|
||||
vcs?: HostPluginApi["state"]["vcs"]
|
||||
workspace?: Partial<HostPluginApi["state"]["workspace"]>
|
||||
session?: Partial<HostPluginApi["state"]["session"]>
|
||||
part?: HostPluginApi["state"]["part"]
|
||||
lsp?: HostPluginApi["state"]["lsp"]
|
||||
@@ -277,15 +276,11 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
|
||||
return opts.state?.provider ?? []
|
||||
},
|
||||
get path() {
|
||||
return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" }
|
||||
return opts.state?.path ?? { home: "", state: "", config: "", worktree: "", directory: "" }
|
||||
},
|
||||
get vcs() {
|
||||
return opts.state?.vcs
|
||||
},
|
||||
workspace: {
|
||||
list: opts.state?.workspace?.list ?? (() => []),
|
||||
get: opts.state?.workspace?.get ?? (() => undefined),
|
||||
},
|
||||
session: {
|
||||
count: opts.state?.session?.count ?? (() => 0),
|
||||
diff: opts.state?.session?.diff ?? (() => []),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Log } from "../../src/util/log"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
Log.init({ print: false })
|
||||
@@ -140,3 +141,25 @@ describe("step-finish token propagation via Bus event", () => {
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
})
|
||||
|
||||
describe("Session", () => {
|
||||
test("remove works without an instance", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const session = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "remove-without-instance" }),
|
||||
})
|
||||
|
||||
await expect(async () => {
|
||||
await Session.remove(session.id)
|
||||
}).not.toThrow()
|
||||
|
||||
let missing = false
|
||||
await Session.get(session.id).catch(() => {
|
||||
missing = true
|
||||
})
|
||||
|
||||
expect(missing).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -272,10 +272,6 @@ export type TuiState = {
|
||||
directory: string
|
||||
}
|
||||
readonly vcs: { branch?: string } | undefined
|
||||
readonly workspace: {
|
||||
list: () => ReadonlyArray<Workspace>
|
||||
get: (workspaceID: string) => Workspace | undefined
|
||||
}
|
||||
session: {
|
||||
count: () => number
|
||||
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
ExperimentalWorkspaceListResponses,
|
||||
ExperimentalWorkspaceRemoveErrors,
|
||||
ExperimentalWorkspaceRemoveResponses,
|
||||
ExperimentalWorkspaceStatusResponses,
|
||||
FileListResponses,
|
||||
FilePartInput,
|
||||
FilePartSource,
|
||||
@@ -1163,6 +1164,36 @@ export class Workspace extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace status
|
||||
*
|
||||
* Get connection status for workspaces in the current project.
|
||||
*/
|
||||
public status<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<ExperimentalWorkspaceStatusResponses, unknown, ThrowOnError>({
|
||||
url: "/experimental/workspace/status",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove workspace
|
||||
*
|
||||
|
||||
@@ -330,6 +330,15 @@ export type EventWorkspaceFailed = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorkspaceStatus = {
|
||||
type: "workspace.status"
|
||||
properties: {
|
||||
workspaceID: string
|
||||
status: "connected" | "connecting" | "disconnected" | "error"
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type QuestionOption = {
|
||||
/**
|
||||
* Display text (1-5 words, concise)
|
||||
@@ -988,6 +997,7 @@ export type Event =
|
||||
| EventCommandExecuted
|
||||
| EventWorkspaceReady
|
||||
| EventWorkspaceFailed
|
||||
| EventWorkspaceStatus
|
||||
| EventQuestionAsked
|
||||
| EventQuestionReplied
|
||||
| EventQuestionRejected
|
||||
@@ -2857,6 +2867,30 @@ export type ExperimentalWorkspaceCreateResponses = {
|
||||
export type ExperimentalWorkspaceCreateResponse =
|
||||
ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]
|
||||
|
||||
export type ExperimentalWorkspaceStatusData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/experimental/workspace/status"
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceStatusResponses = {
|
||||
/**
|
||||
* Workspace status
|
||||
*/
|
||||
200: Array<{
|
||||
workspaceID: string
|
||||
status: "connected" | "connecting" | "disconnected" | "error"
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceStatusResponse =
|
||||
ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses]
|
||||
|
||||
export type ExperimentalWorkspaceRemoveData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@@ -1656,6 +1656,64 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/workspace/status": {
|
||||
"get": {
|
||||
"operationId": "experimental.workspace.status",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "workspace",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": "Workspace status",
|
||||
"description": "Get connection status for workspaces in the current project.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Workspace status",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspaceID": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk.*"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["connected", "connecting", "disconnected", "error"]
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceID", "status"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/workspace/{id}": {
|
||||
"delete": {
|
||||
"operationId": "experimental.workspace.remove",
|
||||
@@ -7966,6 +8024,33 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.workspace.status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "workspace.status"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspaceID": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk.*"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["connected", "connecting", "disconnected", "error"]
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceID", "status"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"QuestionOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9858,6 +9943,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.workspace.failed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.workspace.status"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.asked"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user