feat(app): delete workspace
This commit is contained in:
@@ -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>
|
||||
<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}
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</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")}
|
||||
</div>
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
onClick={() => navigate(`/${slug()}/session`)}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<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`)}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user