feat(tui): add workspace management dialog (#29612)
This commit is contained in:
@@ -41,6 +41,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list"
|
|||||||
import { DialogHelp } from "./ui/dialog-help"
|
import { DialogHelp } from "./ui/dialog-help"
|
||||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
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 { DialogConsoleOrg } from "@tui/component/dialog-console-org"
|
||||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||||
import { Home } from "@tui/routes/home"
|
import { Home } from "@tui/routes/home"
|
||||||
@@ -114,6 +115,7 @@ const appBindingCommands = [
|
|||||||
"theme.mode.lock",
|
"theme.mode.lock",
|
||||||
"help.show",
|
"help.show",
|
||||||
"docs.open",
|
"docs.open",
|
||||||
|
"workspace.list",
|
||||||
"app.debug",
|
"app.debug",
|
||||||
"app.console",
|
"app.console",
|
||||||
"app.heap_snapshot",
|
"app.heap_snapshot",
|
||||||
@@ -606,6 +608,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
dialog.clear()
|
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) => ({
|
...Array.from({ length: 9 }, (_, i) => ({
|
||||||
name: `session.quick_switch.${i + 1}`,
|
name: `session.quick_switch.${i + 1}`,
|
||||||
title: `Switch to session in quick slot ${i + 1}`,
|
title: `Switch to session in quick slot ${i + 1}`,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ export interface DialogSelectOption<T = any> {
|
|||||||
title: string
|
title: string
|
||||||
value: T
|
value: T
|
||||||
description?: string
|
description?: string
|
||||||
|
details?: string[]
|
||||||
footer?: JSX.Element | string
|
footer?: JSX.Element | string
|
||||||
category?: string
|
category?: string
|
||||||
categoryView?: JSX.Element
|
categoryView?: JSX.Element
|
||||||
@@ -167,7 +168,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
if (!category) return acc
|
if (!category) return acc
|
||||||
return acc + (i > 0 ? 2 : 1)
|
return acc + (i > 0 ? 2 : 1)
|
||||||
}, 0)
|
}, 0)
|
||||||
return flat().length + headers
|
return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers)
|
||||||
})
|
})
|
||||||
|
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
@@ -426,7 +427,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
id={JSON.stringify(option.value)}
|
id={JSON.stringify(option.value)}
|
||||||
flexDirection="row"
|
flexDirection="column"
|
||||||
position="relative"
|
position="relative"
|
||||||
onMouseMove={() => {
|
onMouseMove={() => {
|
||||||
setStore("input", "mouse")
|
setStore("input", "mouse")
|
||||||
@@ -446,24 +447,37 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
moveTo(index)
|
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
|
||||||
<box position="absolute" left={1} flexShrink={0}>
|
flexDirection="row"
|
||||||
{option.margin}
|
paddingLeft={current() || option.gutter ? 1 : 3}
|
||||||
</box>
|
paddingRight={3}
|
||||||
</Show>
|
gap={1}
|
||||||
<Option
|
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||||
title={option.title}
|
>
|
||||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
<Show when={!current() && option.margin}>
|
||||||
description={option.description !== category ? option.description : undefined}
|
<box position="absolute" left={1} flexShrink={0}>
|
||||||
active={active()}
|
{option.margin}
|
||||||
current={current()}
|
</box>
|
||||||
gutter={option.gutter}
|
</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>
|
</box>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export const layer: Layer.Layer<
|
|||||||
return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
|
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()
|
const primaryName = pathSvc.basename(primary).toLowerCase()
|
||||||
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
|
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -214,6 +214,22 @@ describe("Worktree", () => {
|
|||||||
{ git: true },
|
{ 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(
|
it.instance(
|
||||||
"create with custom name",
|
"create with custom name",
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user