feat(tui): add workspace management dialog (#29612)

This commit is contained in:
James Long 2026-05-27 14:09:40 -04:00 committed by GitHub
parent 5a5d981c4e
commit 28a06e52fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 174 additions and 20 deletions

View file

@ -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<string[]> }) {
dialog.clear()
},
},
{
name: "workspace.list",
title: "Manage workspaces",
category: "Workspace",
hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slashName: "workspaces",
run: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,

View file

@ -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<string>()
const [removing, setRemoving] = createSignal<string>()
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
const current = createMemo(() => {
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
return project.workspace.current()
})
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
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: () => <text fg={status === "connected" ? theme.success : theme.error}></text>,
}
}),
)
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 (
<DialogSelect
title="Workspaces"
options={options()}
onMove={(option) => {
setDeleting(undefined)
}}
onSelect={(option) => showDetails(option.value.workspace)}
actions={[
{
command: "session.delete",
title: "delete",
onTrigger: (option) => void remove(option.value.workspace),
},
]}
/>
)
}

View file

@ -51,6 +51,7 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
details?: string[]
footer?: JSX.Element | string
category?: string
categoryView?: JSX.Element
@ -167,7 +168,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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<T>(props: DialogSelectProps<T>) {
return (
<box
id={JSON.stringify(option.value)}
flexDirection="row"
flexDirection="column"
position="relative"
onMouseMove={() => {
setStore("input", "mouse")
@ -446,24 +447,37 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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}
>
<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}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
<box
flexDirection="row"
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
>
<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}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
</box>
<For each={option.details}>
{(detail) => (
<box paddingLeft={3} paddingRight={3}>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
</text>
</box>
)}
</For>
</box>
)
}}

View file

@ -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* () {

View file

@ -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",
() =>