From 28a06e52fcfaea87a749e4e4c9a74d90b3195fb0 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 27 May 2026 14:09:40 -0400 Subject: [PATCH] feat(tui): add workspace management dialog (#29612) --- packages/opencode/src/cli/cmd/tui/app.tsx | 12 ++ .../tui/component/dialog-workspace-list.tsx | 112 ++++++++++++++++++ .../src/cli/cmd/tui/ui/dialog-select.tsx | 52 +++++--- packages/opencode/src/worktree/index.ts | 2 +- .../opencode/test/project/worktree.test.ts | 16 +++ 5 files changed, 174 insertions(+), 20 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 39182dac0e..68803d0d11 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -41,6 +41,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" 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 { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" @@ -114,6 +115,7 @@ const appBindingCommands = [ "theme.mode.lock", "help.show", "docs.open", + "workspace.list", "app.debug", "app.console", "app.heap_snapshot", @@ -606,6 +608,16 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.clear() }, }, + { + name: "workspace.list", + title: "Manage workspaces", + category: "Workspace", + hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + slashName: "workspaces", + run: () => { + dialog.replace(() => ) + }, + }, ...Array.from({ length: 9 }, (_, i) => ({ name: `session.quick_switch.${i + 1}`, title: `Switch to session in quick slot ${i + 1}`, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx new file mode 100644 index 0000000000..0790d64796 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx @@ -0,0 +1,112 @@ +import type { Workspace } from "@opencode-ai/sdk/v2" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { useProject } from "@tui/context/project" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { useTheme } from "@tui/context/theme" +import { createMemo, createSignal, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { errorMessage } from "@/util/error" +import { useSDK } from "../context/sdk" +import { useToast } from "../ui/toast" + +type WorkspaceOption = { workspace: Workspace } + +export function DialogWorkspaceList() { + const dialog = useDialog() + const route = useRoute() + const sync = useSync() + const sdk = useSDK() + const toast = useToast() + const project = useProject() + const { theme } = useTheme() + const [deleting, setDeleting] = createSignal() + const [removing, setRemoving] = createSignal() + const [expanded, setExpanded] = createStore>({}) + + const current = createMemo(() => { + if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID + return project.workspace.current() + }) + + const options = createMemo[]>(() => + project.workspace + .list() + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((workspace) => { + const status = project.workspace.status(workspace.id) + return { + title: + removing() === workspace.id + ? "Deleting..." + : deleting() === workspace.id + ? `Delete ${workspace.name}? Press delete again` + : workspace.name, + value: { workspace }, + footer: workspace.type, + details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined, + gutter: () => , + } + }), + ) + + function showDetails(workspace: Workspace) { + setExpanded(workspace.id, (open) => !open) + } + + async function remove(workspace: Workspace) { + if (removing()) return + if (deleting() !== workspace.id) { + setDeleting(workspace.id) + return + } + + setDeleting(undefined) + setRemoving(workspace.id) + const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({ + error: err, + })) + if (result?.error) { + setRemoving(undefined) + toast.show({ + variant: "error", + title: "Failed to delete workspace", + message: errorMessage(result.error), + }) + return + } + + if (current() === workspace.id) { + project.workspace.set(undefined) + route.navigate({ type: "home" }) + } + await project.workspace.sync() + await sync.bootstrap({ fatal: false }).catch(() => undefined) + setRemoving(undefined) + } + + onMount(() => { + dialog.setSize("large") + void sdk.client.experimental.workspace.syncList().catch(() => undefined) + void project.workspace.sync() + }) + + return ( + { + setDeleting(undefined) + }} + onSelect={(option) => showDetails(option.value.workspace)} + actions={[ + { + command: "session.delete", + title: "delete", + onTrigger: (option) => void remove(option.value.workspace), + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 700735d38c..a9e9730955 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -51,6 +51,7 @@ export interface DialogSelectOption { title: string value: T description?: string + details?: string[] footer?: JSX.Element | string category?: string categoryView?: JSX.Element @@ -167,7 +168,7 @@ export function DialogSelect(props: DialogSelectProps) { if (!category) return acc return acc + (i > 0 ? 2 : 1) }, 0) - return flat().length + headers + return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers) }) const dimensions = useTerminalDimensions() @@ -426,7 +427,7 @@ export function DialogSelect(props: DialogSelectProps) { return ( { setStore("input", "mouse") @@ -446,24 +447,37 @@ export function DialogSelect(props: DialogSelectProps) { if (index === -1) return moveTo(index) }} - backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} - paddingLeft={current() || option.gutter ? 1 : 3} - paddingRight={3} - gap={1} > - - - {option.margin} - - - ) }} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index a1d4f89c2a..d9743e563b 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -351,7 +351,7 @@ export const layer: Layer.Layer< return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) } - const primary = yield* canonical(ctx.worktree) + const primary = yield* canonical(ctx.project.worktree) const primaryName = pathSvc.basename(primary).toLowerCase() return yield* Effect.forEach(parseWorktreeList(result.text), (entry) => Effect.gen(function* () { diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 688b818bee..fedf98371e 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -214,6 +214,22 @@ describe("Worktree", () => { { git: true }, ) + it.instance( + "lists the active linked worktree but not the project checkout", + () => + withCreatedWorktree(undefined, ({ info }) => + Effect.gen(function* () { + const test = yield* TestInstance + const svc = yield* Worktree.Service + const list = yield* svc.list().pipe(provideInstance(info.directory)) + + expect(list.map((item) => item.name)).toContain(info.name) + expect(list.map((item) => item.name)).not.toContain(path.basename(test.directory).toLowerCase()) + }), + ), + { git: true }, + ) + it.instance( "create with custom name", () =>