ソースを参照

feat(app): delete workspace

Adam 2 ヶ月 前
コミット
f26de6c52f

+ 168 - 59
packages/app/src/pages/layout.tsx

@@ -32,6 +32,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Collapsible } from "@opencode-ai/ui/collapsible"
 import { DiffChanges } from "@opencode-ai/ui/diff-changes"
 import { Spinner } from "@opencode-ai/ui/spinner"
+import { Dialog } from "@opencode-ai/ui/dialog"
 import { getFilename } from "@opencode-ai/util/path"
 import { Session } from "@opencode-ai/sdk/v2/client"
 import { usePlatform } from "@/context/platform"
@@ -906,6 +907,99 @@ export default function Layout(props: ParentProps) {
     }
   }
 
+  const errorMessage = (err: unknown) => {
+    if (err && typeof err === "object" && "data" in err) {
+      const data = (err as { data?: { message?: string } }).data
+      if (data?.message) return data.message
+    }
+    if (err instanceof Error) return err.message
+    return "Request failed"
+  }
+
+  const deleteWorkspace = async (directory: string) => {
+    const current = currentProject()
+    if (!current) return
+    if (directory === current.worktree) return
+
+    const result = await globalSDK.client.worktree
+      .remove({ directory: current.worktree, worktreeRemoveInput: { directory } })
+      .then((x) => x.data)
+      .catch((err) => {
+        showToast({
+          title: "Failed to delete workspace",
+          description: errorMessage(err),
+        })
+        return false
+      })
+
+    if (!result) return
+
+    layout.projects.close(directory)
+    layout.projects.open(current.worktree)
+
+    if (params.dir && base64Decode(params.dir) === directory) {
+      navigateToProject(current.worktree)
+    }
+  }
+
+  function DialogDeleteWorkspace(props: { directory: string }) {
+    const name = createMemo(() => getFilename(props.directory))
+    const [data, setData] = createStore({
+      status: "loading" as "loading" | "ready" | "error",
+      dirty: false,
+    })
+
+    onMount(() => {
+      const current = currentProject()
+      if (!current) {
+        setData({ status: "error", dirty: false })
+        return
+      }
+
+      globalSDK.client.file
+        .status({ directory: props.directory })
+        .then((x) => {
+          const files = x.data ?? []
+          const dirty = files.length > 0
+          setData({ status: "ready", dirty })
+        })
+        .catch(() => {
+          setData({ status: "error", dirty: false })
+        })
+    })
+
+    const handleDelete = async () => {
+      await deleteWorkspace(props.directory)
+      dialog.close()
+    }
+
+    const description = () => {
+      if (data.status === "loading") return "Checking for unmerged changes..."
+      if (data.status === "error") return "Unable to verify git status."
+      if (!data.dirty) return "No unmerged changes detected."
+      return "Unmerged changes detected in this workspace."
+    }
+
+    return (
+      <Dialog title="Delete workspace">
+        <div class="flex flex-col gap-4 px-2.5 pb-3">
+          <div class="flex flex-col gap-1">
+            <span class="text-14-regular text-text-strong">Delete workspace "{name()}"?</span>
+            <span class="text-12-regular text-text-weak">{description()}</span>
+          </div>
+          <div class="flex justify-end gap-2">
+            <Button variant="ghost" size="large" onClick={() => dialog.close()}>
+              Cancel
+            </Button>
+            <Button variant="primary" size="large" disabled={data.status === "loading"} onClick={handleDelete}>
+              Delete workspace
+            </Button>
+          </div>
+        </div>
+      </Dialog>
+    )
+  }
+
   createEffect(
     on(
       () => ({ ready: pageReady(), dir: params.dir, id: params.id }),
@@ -1205,6 +1299,7 @@ export default function Layout(props: ParentProps) {
   const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
     const sortable = createSortable(props.directory)
     const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
+    const [menuOpen, setMenuOpen] = createSignal(false)
     const slug = createMemo(() => base64Encode(props.directory))
     const sessions = createMemo(() =>
       workspaceStore.session
@@ -1239,62 +1334,85 @@ export default function Layout(props: ParentProps) {
       <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
         <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
           <div class="px-2 py-1">
-            <div class="group/trigger relative">
-              <Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
-                <div class="flex items-center gap-1 min-w-0 flex-1">
-                  <div class="flex items-center justify-center shrink-0 size-6">
-                    <Icon name="branch" size="small" />
+            <div class="group/workspace relative">
+              <div class="flex items-center gap-1">
+                <Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
+                  <div class="flex items-center gap-1 min-w-0 flex-1">
+                    <div class="flex items-center justify-center shrink-0 size-6">
+                      <Icon name="branch" size="small" />
+                    </div>
+                    <span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
+                    <Show
+                      when={!local()}
+                      fallback={
+                        <span class="text-14-medium text-text-base min-w-0 truncate">
+                          {workspaceStore.vcs?.branch ?? getFilename(props.directory)}
+                        </span>
+                      }
+                    >
+                      <InlineEditor
+                        id={`workspace:${props.directory}`}
+                        value={workspaceValue}
+                        onSave={(next) => {
+                          const trimmed = next.trim()
+                          if (!trimmed) return
+                          renameWorkspace(props.directory, trimmed)
+                          setEditor("value", workspaceValue())
+                        }}
+                        class="text-14-medium text-text-base min-w-0 truncate"
+                        displayClass="text-14-medium text-text-base min-w-0 truncate"
+                        editing={workspaceEditActive()}
+                        stopPropagation={false}
+                        openOnDblClick={false}
+                      />
+                    </Show>
+                    <Icon
+                      name={open() ? "chevron-down" : "chevron-right"}
+                      size="small"
+                      class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
+                    />
                   </div>
-                  <span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
-                  <Show
-                    when={!local()}
-                    fallback={
-                      <span class="text-14-medium text-text-base min-w-0 truncate">
-                        {workspaceStore.vcs?.branch ?? getFilename(props.directory)}
-                      </span>
-                    }
-                  >
-                    <InlineEditor
-                      id={`workspace:${props.directory}`}
-                      value={workspaceValue}
-                      onSave={(next) => {
-                        const trimmed = next.trim()
-                        if (!trimmed) return
-                        renameWorkspace(props.directory, trimmed)
-                        setEditor("value", workspaceValue())
-                      }}
-                      class="text-14-medium text-text-base min-w-0 truncate"
-                      displayClass="text-14-medium text-text-base min-w-0 truncate"
-                      editing={workspaceEditActive()}
-                      stopPropagation={false}
-                      openOnDblClick={false}
+                </Collapsible.Trigger>
+                <div
+                  class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
+                  classList={{
+                    "opacity-100 pointer-events-auto": menuOpen(),
+                    "opacity-0 pointer-events-none": !menuOpen(),
+                    "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
+                    "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
+                  }}
+                >
+                  <DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
+                    <Tooltip value="More options" placement="top">
+                      <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" class="size-6 rounded-md" />
+                    </Tooltip>
+                    <DropdownMenu.Portal>
+                      <DropdownMenu.Content>
+                        <DropdownMenu.Item onSelect={() => navigate(`/${slug()}/session`)}>
+                          <DropdownMenu.ItemLabel>New session</DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                        <DropdownMenu.Item
+                          disabled={local()}
+                          onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
+                        >
+                          <DropdownMenu.ItemLabel>Delete workspace</DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                      </DropdownMenu.Content>
+                    </DropdownMenu.Portal>
+                  </DropdownMenu>
+                  <TooltipKeybind placement="right" title="New session" keybind={command.keybind("session.new")}>
+                    <IconButton
+                      icon="plus-small"
+                      variant="ghost"
+                      class="size-6 rounded-md"
+                      onClick={() => navigate(`/${slug()}/session`)}
                     />
-                  </Show>
-                  <Icon
-                    name={open() ? "chevron-down" : "chevron-right"}
-                    size="small"
-                    class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/trigger:opacity-100 group-focus-within/trigger:opacity-100"
-                  />
+                  </TooltipKeybind>
                 </div>
-              </Collapsible.Trigger>
-              <div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
-                <IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md pointer-events-auto" />
-                <TooltipKeybind
-                  class="pointer-events-auto"
-                  placement="right"
-                  title="New session"
-                  keybind={command.keybind("session.new")}
-                >
-                  <IconButton
-                    icon="plus-small"
-                    variant="ghost"
-                    class="size-6 rounded-md"
-                    onClick={() => navigate(`/${slug()}/session`)}
-                  />
-                </TooltipKeybind>
               </div>
             </div>
           </div>
+
           <Collapsible.Content>
             <nav class="flex flex-col gap-1 px-2">
               <Button
@@ -1520,15 +1638,6 @@ export default function Layout(props: ParentProps) {
     const projectId = createMemo(() => project()?.id ?? "")
     const workspaces = createMemo(() => workspaceIds(project()))
 
-    const errorMessage = (err: unknown) => {
-      if (err && typeof err === "object" && "data" in err) {
-        const data = (err as { data?: { message?: string } }).data
-        if (data?.message) return data.message
-      }
-      if (err instanceof Error) return err.message
-      return "Request failed"
-    }
-
     const createWorkspace = async () => {
       const current = project()
       if (!current) return

+ 15 - 0
packages/opencode/src/project/project.ts

@@ -317,4 +317,19 @@ export namespace Project {
     }
     return valid
   }
+
+  export async function removeSandbox(projectID: string, directory: string) {
+    const result = await Storage.update<Info>(["project", projectID], (draft) => {
+      const sandboxes = draft.sandboxes ?? []
+      draft.sandboxes = sandboxes.filter((sandbox) => sandbox !== directory)
+      draft.time.updated = Date.now()
+    })
+    GlobalBus.emit("event", {
+      payload: {
+        type: Event.Updated.type,
+        properties: result,
+      },
+    })
+    return result
+  }
 }

+ 26 - 0
packages/opencode/src/server/routes/experimental.ts

@@ -133,6 +133,32 @@ export const ExperimentalRoutes = lazy(() =>
         return c.json(sandboxes)
       },
     )
+    .delete(
+      "/worktree",
+      describeRoute({
+        summary: "Remove worktree",
+        description: "Remove a git worktree and delete its branch.",
+        operationId: "worktree.remove",
+        responses: {
+          200: {
+            description: "Worktree removed",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator("json", Worktree.remove.schema),
+      async (c) => {
+        const body = c.req.valid("json")
+        await Worktree.remove(body)
+        await Project.removeSandbox(Instance.project.id, body.directory)
+        return c.json(true)
+      },
+    )
     .get(
       "/resource",
       describeRoute({

+ 66 - 0
packages/opencode/src/worktree/index.ts

@@ -33,6 +33,16 @@ export namespace Worktree {
 
   export type CreateInput = z.infer<typeof CreateInput>
 
+  export const RemoveInput = z
+    .object({
+      directory: z.string(),
+    })
+    .meta({
+      ref: "WorktreeRemoveInput",
+    })
+
+  export type RemoveInput = z.infer<typeof RemoveInput>
+
   export const NotGitError = NamedError.create(
     "WorktreeNotGitError",
     z.object({
@@ -61,6 +71,13 @@ export namespace Worktree {
     }),
   )
 
+  export const RemoveFailedError = NamedError.create(
+    "WorktreeRemoveFailedError",
+    z.object({
+      message: z.string(),
+    }),
+  )
+
   const ADJECTIVES = [
     "brave",
     "calm",
@@ -214,4 +231,53 @@ export namespace Worktree {
 
     return info
   })
+
+  export const remove = fn(RemoveInput, async (input) => {
+    if (Instance.project.vcs !== "git") {
+      throw new NotGitError({ message: "Worktrees are only supported for git projects" })
+    }
+
+    const directory = path.resolve(input.directory)
+    const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
+    if (list.exitCode !== 0) {
+      throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
+    }
+
+    const lines = outputText(list.stdout)
+      .split("\n")
+      .map((line) => line.trim())
+    const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
+      if (!line) return acc
+      if (line.startsWith("worktree ")) {
+        acc.push({ path: line.slice("worktree ".length).trim() })
+        return acc
+      }
+      const current = acc[acc.length - 1]
+      if (!current) return acc
+      if (line.startsWith("branch ")) {
+        current.branch = line.slice("branch ".length).trim()
+      }
+      return acc
+    }, [])
+
+    const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
+    if (!entry?.path) {
+      throw new RemoveFailedError({ message: "Worktree not found" })
+    }
+
+    const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
+    if (removed.exitCode !== 0) {
+      throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
+    }
+
+    const branch = entry.branch?.replace(/^refs\/heads\//, "")
+    if (branch) {
+      const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
+      if (deleted.exitCode !== 0) {
+        throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
+      }
+    }
+
+    return true
+  })
 }

+ 38 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -162,6 +162,9 @@ import type {
   WorktreeCreateInput,
   WorktreeCreateResponses,
   WorktreeListResponses,
+  WorktreeRemoveErrors,
+  WorktreeRemoveInput,
+  WorktreeRemoveResponses,
 } from "./types.gen.js"
 
 export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<
@@ -654,6 +657,41 @@ export class Tool extends HeyApiClient {
 }
 
 export class Worktree extends HeyApiClient {
+  /**
+   * Remove worktree
+   *
+   * Remove a git worktree and delete its branch.
+   */
+  public remove<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      worktreeRemoveInput?: WorktreeRemoveInput
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { key: "worktreeRemoveInput", map: "body" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).delete<WorktreeRemoveResponses, WorktreeRemoveErrors, ThrowOnError>({
+      url: "/experimental/worktree",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
   /**
    * List worktrees
    *

+ 31 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -1908,6 +1908,10 @@ export type WorktreeCreateInput = {
   startCommand?: string
 }
 
+export type WorktreeRemoveInput = {
+  directory: string
+}
+
 export type McpResource = {
   name: string
   uri: string
@@ -2554,6 +2558,33 @@ export type ToolListResponses = {
 
 export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
 
+export type WorktreeRemoveData = {
+  body?: WorktreeRemoveInput
+  path?: never
+  query?: {
+    directory?: string
+  }
+  url: "/experimental/worktree"
+}
+
+export type WorktreeRemoveErrors = {
+  /**
+   * Bad request
+   */
+  400: BadRequestError
+}
+
+export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors]
+
+export type WorktreeRemoveResponses = {
+  /**
+   * Worktree removed
+   */
+  200: boolean
+}
+
+export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses]
+
 export type WorktreeListData = {
   body?: never
   path?: never