summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-15 12:48:54 -0600
committerAdam <[email protected]>2026-01-19 07:35:52 -0600
commitf26de6c52f7442762973155c26743d3494fb5887 (patch)
tree7897e0c11a5caea28ffa2be84cf8503a19967407
parent06d03dec3b1a69a395082fcba9b1e75378b4f045 (diff)
downloadopencode-f26de6c52f7442762973155c26743d3494fb5887.tar.gz
opencode-f26de6c52f7442762973155c26743d3494fb5887.zip
feat(app): delete workspace
-rw-r--r--packages/app/src/pages/layout.tsx227
-rw-r--r--packages/opencode/src/project/project.ts15
-rw-r--r--packages/opencode/src/server/routes/experimental.ts26
-rw-r--r--packages/opencode/src/worktree/index.ts66
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts38
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts31
6 files changed, 344 insertions, 59 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 56d6bfbf8..8d61f0510 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/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
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index 72201636b..2cec78623 100644
--- a/packages/opencode/src/project/project.ts
+++ b/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
+ }
}
diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts
index 0fb2a5e9d..d300d3bf8 100644
--- a/packages/opencode/src/server/routes/experimental.ts
+++ b/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({
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index b1ac8fbfc..be10fd83d 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/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
+ })
}
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 6f6993199..ba299f81f 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/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<
@@ -655,6 +658,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
*
* List all sandbox worktrees for the current project.
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 04e7144eb..58d3c3ae2 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/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