diff options
| author | Adam <[email protected]> | 2026-01-15 12:48:54 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-19 07:35:52 -0600 |
| commit | f26de6c52f7442762973155c26743d3494fb5887 (patch) | |
| tree | 7897e0c11a5caea28ffa2be84cf8503a19967407 | |
| parent | 06d03dec3b1a69a395082fcba9b1e75378b4f045 (diff) | |
| download | opencode-f26de6c52f7442762973155c26743d3494fb5887.tar.gz opencode-f26de6c52f7442762973155c26743d3494fb5887.zip | |
feat(app): delete workspace
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 227 | ||||
| -rw-r--r-- | packages/opencode/src/project/project.ts | 15 | ||||
| -rw-r--r-- | packages/opencode/src/server/routes/experimental.ts | 26 | ||||
| -rw-r--r-- | packages/opencode/src/worktree/index.ts | 66 | ||||
| -rw-r--r-- | packages/sdk/js/src/v2/gen/sdk.gen.ts | 38 | ||||
| -rw-r--r-- | packages/sdk/js/src/v2/gen/types.gen.ts | 31 |
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 |
