Просмотр исходного кода

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

James Long 3 месяцев назад
Родитель
Сommit
28a06e52fc

+ 12 - 0
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<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}`,

+ 112 - 0
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<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),
+        },
+      ]}
+    />
+  )
+}

+ 33 - 19
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -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>
                     )
                   }}

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

+ 16 - 0
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",
       () =>