Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
type DestroyWorkspaceError,
type DestroyWorkspaceInput,
type DestroyWorkspaceSuccess,
type UseDestroyWorkspace,
useDestroyWorkspace,
} from "./useDestroyWorkspace";
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { TeardownFailureCause } from "@superset/host-service";
import { TRPCClientError } from "@trpc/client";
import { useCallback } from "react";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { useWorkspaceHostUrl } from "../useWorkspaceHostUrl";

export interface DestroyWorkspaceInput {
deleteBranch?: boolean;
force?: boolean;
}

export interface DestroyWorkspaceSuccess {
success: boolean;
worktreeRemoved: boolean;
branchDeleted: boolean;
cloudDeleted: boolean;
warnings: string[];
}

export type DestroyWorkspaceError =
| { kind: "conflict"; message: string }
| { kind: "teardown-failed"; cause: TeardownFailureCause }
| { kind: "unknown"; message: string };

export interface UseDestroyWorkspace {
destroy: (input?: DestroyWorkspaceInput) => Promise<DestroyWorkspaceSuccess>;
}

/**
* Calls `workspaceCleanup.destroy` on the workspace's owning host-service.
* Translates TRPC errors into a typed discriminated union so callers can
* prompt for `force: true` on conflict or teardown failure.
*
* Throws a DestroyWorkspaceError (not a TRPCClientError) for easier handling.
*/
export function useDestroyWorkspace(workspaceId: string): UseDestroyWorkspace {
const hostUrl = useWorkspaceHostUrl(workspaceId);

const destroy = useCallback(
async (
input: DestroyWorkspaceInput = {},
): Promise<DestroyWorkspaceSuccess> => {
if (!hostUrl) {
throw {
kind: "unknown",
message: "Host unavailable",
} satisfies DestroyWorkspaceError;
}

const client = getHostServiceClientByUrl(hostUrl);
try {
const result = await client.workspaceCleanup.destroy.mutate({
workspaceId,
deleteBranch: input.deleteBranch ?? false,
force: input.force ?? false,
});
return result;
} catch (err) {
throw normalizeError(err);
}
},
[hostUrl, workspaceId],
);

return { destroy };
}

function normalizeError(err: unknown): DestroyWorkspaceError {
if (err instanceof TRPCClientError) {
const code = err.data?.code as string | undefined;
const teardownFailure = (
err.data as { teardownFailure?: TeardownFailureCause }
)?.teardownFailure;

if (teardownFailure) {
return { kind: "teardown-failed", cause: teardownFailure };
}
if (code === "CONFLICT") {
return { kind: "conflict", message: err.message };
}
return { kind: "unknown", message: err.message };
}
return {
kind: "unknown",
message: err instanceof Error ? err.message : String(err),
};
}
Original file line number Diff line number Diff line change
@@ -1,61 +1,85 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@superset/ui/alert-dialog";
import { Button } from "@superset/ui/button";
import { ConflictPane } from "./components/ConflictPane";
import { DestroyConfirmPane } from "./components/DestroyConfirmPane";
import { TeardownFailedPane } from "./components/TeardownFailedPane";
import { UnknownErrorPane } from "./components/UnknownErrorPane";
import { useDestroyDialogState } from "./hooks/useDestroyDialogState";

interface DashboardSidebarDeleteDialogProps {
workspaceId: string;
workspaceName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title: string;
description: string;
isPending?: boolean;
/** Fires after a successful destroy (any warnings reported via toast). */
onDeleted?: () => void;
}

/**
* Dispatches between confirm / conflict / teardown-failed / unknown-error
* panes based on the error returned by `workspaceCleanup.destroy`. The
* destroy itself runs in the background under a toast — this dialog is
* only on screen when the user has a decision to make.
*/
export function DashboardSidebarDeleteDialog({
workspaceId,
workspaceName,
open,
onOpenChange,
onConfirm,
title,
description,
isPending = false,
onDeleted,
}: DashboardSidebarDeleteDialogProps) {
const {
deleteBranch,
setDeleteBranch,
error,
clearError,
handleOpenChange,
run,
} = useDestroyDialogState({
workspaceId,
workspaceName,
onOpenChange,
onDeleted,
});

if (error?.kind === "conflict") {
return (
<ConflictPane
open={open}
onOpenChange={handleOpenChange}
onForceDelete={() => run(true)}
/>
);
}

if (error?.kind === "teardown-failed") {
return (
<TeardownFailedPane
open={open}
onOpenChange={handleOpenChange}
cause={error.cause}
onForceDelete={() => run(true)}
/>
);
}

if (error?.kind === "unknown") {
return (
<UnknownErrorPane
open={open}
onOpenChange={handleOpenChange}
message={error.message}
onRetry={clearError}
/>
);
}

return (
<AlertDialog
<DestroyConfirmPane
open={open}
onOpenChange={isPending ? undefined : onOpenChange}
>
<AlertDialogContent className="max-w-[340px] gap-0 p-0">
<AlertDialogHeader className="px-4 pt-4 pb-2">
<AlertDialogTitle className="font-medium">{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="px-4 pb-4 pt-2 flex-row justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-3 text-xs"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
className="h-7 px-3 text-xs"
onClick={onConfirm}
disabled={isPending}
>
{isPending ? "Deleting..." : "Delete"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
onOpenChange={handleOpenChange}
workspaceName={workspaceName}
deleteBranch={deleteBranch}
onDeleteBranchChange={setDeleteBranch}
onConfirm={() => run(false)}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@superset/ui/alert-dialog";
import { Button } from "@superset/ui/button";

interface ConflictPaneProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Re-runs destroy with `force: true`. */
onForceDelete: () => void;
}

/** Shown when the preflight dirty-worktree check blocks destroy. */
export function ConflictPane({
open,
onOpenChange,
onForceDelete,
}: ConflictPaneProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="max-w-[380px] gap-0 p-0">
<AlertDialogHeader className="px-4 pt-4 pb-2">
<AlertDialogTitle className="font-medium">
Uncommitted changes in worktree
</AlertDialogTitle>
<AlertDialogDescription>
The worktree has uncommitted changes. Delete anyway will discard
them.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="px-4 pb-4 pt-2 flex-row justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-3 text-xs"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
className="h-7 px-3 text-xs"
onClick={onForceDelete}
>
Delete anyway
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ConflictPane } from "./ConflictPane";
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@superset/ui/alert-dialog";
import { Button } from "@superset/ui/button";
import { Checkbox } from "@superset/ui/checkbox";
import { Label } from "@superset/ui/label";
import { useId } from "react";

interface DestroyConfirmPaneProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceName: string;
deleteBranch: boolean;
onDeleteBranchChange: (next: boolean) => void;
onConfirm: () => void;
}

/**
* Default pane: the first click on "Delete". Offers the branch opt-in.
* Confirm hands off to the parent which closes the dialog and runs the
* destroy under a toast — no in-dialog pending state.
*/
export function DestroyConfirmPane({
open,
onOpenChange,
workspaceName,
deleteBranch,
onDeleteBranchChange,
onConfirm,
}: DestroyConfirmPaneProps) {
const checkboxId = useId();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="max-w-[340px] gap-0 p-0">
<AlertDialogHeader className="px-4 pt-4 pb-2">
<AlertDialogTitle className="font-medium">
Delete workspace "{workspaceName}"?
</AlertDialogTitle>
<AlertDialogDescription>
This removes the worktree from disk. The cloud workspace record will
also be removed.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="px-4 pb-2">
<div className="flex items-center gap-2">
<Checkbox
id={checkboxId}
checked={deleteBranch}
onCheckedChange={(checked) =>
onDeleteBranchChange(checked === true)
}
/>
<Label
htmlFor={checkboxId}
className="text-xs text-muted-foreground cursor-pointer select-none"
>
Also delete local branch
</Label>
</div>
</div>
<AlertDialogFooter className="px-4 pb-4 pt-2 flex-row justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-3 text-xs"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
className="h-7 px-3 text-xs"
onClick={onConfirm}
>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DestroyConfirmPane } from "./DestroyConfirmPane";
Loading
Loading