Files
vibe-kanban/frontend/src/components/dialogs/tasks/RestoreLogsDialog.tsx
2025-09-10 18:05:55 +01:00

401 lines
17 KiB
TypeScript

import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle, CheckCircle, GitCommit } from 'lucide-react';
import NiceModal, { useModal } from '@ebay/nice-modal-react';
export interface RestoreLogsDialogProps {
targetSha: string | null;
targetSubject: string | null;
commitsToReset: number | null;
isLinear: boolean | null;
laterCount: number;
laterCoding: number;
laterSetup: number;
laterCleanup: number;
needGitReset: boolean;
canGitReset: boolean;
hasRisk: boolean;
uncommittedCount: number;
untrackedCount: number;
initialWorktreeResetOn: boolean;
initialForceReset: boolean;
}
export type RestoreLogsDialogResult = {
action: 'confirmed' | 'canceled';
performGitReset?: boolean;
forceWhenDirty?: boolean;
};
export const RestoreLogsDialog = NiceModal.create<RestoreLogsDialogProps>(
({
targetSha,
targetSubject,
commitsToReset,
isLinear,
laterCount,
laterCoding,
laterSetup,
laterCleanup,
needGitReset,
canGitReset,
hasRisk,
uncommittedCount,
untrackedCount,
initialWorktreeResetOn,
initialForceReset,
}) => {
const modal = useModal();
const [worktreeResetOn, setWorktreeResetOn] = useState(
initialWorktreeResetOn
);
const [forceReset, setForceReset] = useState(initialForceReset);
const hasLater = laterCount > 0;
const short = targetSha?.slice(0, 7);
// Note: confirm enabling logic handled in footer based on uncommitted changes
const handleConfirm = () => {
modal.resolve({
action: 'confirmed',
performGitReset: worktreeResetOn,
forceWhenDirty: forceReset,
} as RestoreLogsDialogResult);
modal.hide();
};
const handleCancel = () => {
modal.resolve({ action: 'canceled' } as RestoreLogsDialogResult);
modal.hide();
};
const handleOpenChange = (open: boolean) => {
if (!open) {
handleCancel();
}
};
return (
<Dialog open={modal.visible} onOpenChange={handleOpenChange}>
<DialogContent
className="max-h-[92vh] sm:max-h-[88vh] overflow-y-auto overflow-x-hidden"
onKeyDownCapture={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
handleCancel();
}
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 mb-3 md:mb-4">
<AlertTriangle className="h-4 w-4 text-destructive" /> Confirm
Retry
</DialogTitle>
<DialogDescription className="mt-6 break-words">
<div className="space-y-3">
{hasLater && (
<div className="flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/10 p-3">
<AlertTriangle className="h-4 w-4 text-destructive mt-0.5" />
<div className="text-sm min-w-0 w-full break-words">
<p className="font-medium text-destructive mb-2">
History change
</p>
<>
<p className="mt-0.5">
Will delete this process
{laterCount > 0 && (
<>
{' '}
and {laterCount} later process
{laterCount === 1 ? '' : 'es'}
</>
)}{' '}
from history.
</p>
<ul className="mt-1 text-xs text-muted-foreground list-disc pl-5">
{laterCoding > 0 && (
<li>
{laterCoding} coding agent run
{laterCoding === 1 ? '' : 's'}
</li>
)}
{laterSetup + laterCleanup > 0 && (
<li>
{laterSetup + laterCleanup} script process
{laterSetup + laterCleanup === 1 ? '' : 'es'}
{laterSetup > 0 && laterCleanup > 0 && (
<>
{' '}
({laterSetup} setup, {laterCleanup} cleanup)
</>
)}
</li>
)}
</ul>
</>
<p className="mt-1 text-xs text-muted-foreground">
This permanently alters history and cannot be undone.
</p>
</div>
</div>
)}
{needGitReset && canGitReset && (
<div
className={
!worktreeResetOn
? 'flex items-start gap-3 rounded-md border p-3'
: hasRisk
? 'flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/10 p-3'
: 'flex items-start gap-3 rounded-md border p-3 border-amber-300/60 bg-amber-50/70 dark:border-amber-400/30 dark:bg-amber-900/20'
}
>
<AlertTriangle
className={
!worktreeResetOn
? 'h-4 w-4 text-muted-foreground mt-0.5'
: hasRisk
? 'h-4 w-4 text-destructive mt-0.5'
: 'h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5'
}
/>
<div className="text-sm min-w-0 w-full break-words">
<p className="font-medium mb-2">Reset worktree</p>
<div
className="mt-2 w-full flex items-center cursor-pointer select-none"
role="switch"
aria-checked={worktreeResetOn}
onClick={() => setWorktreeResetOn((v) => !v)}
>
<div className="text-xs text-muted-foreground">
{worktreeResetOn ? 'Enabled' : 'Disabled'}
</div>
<div className="ml-auto relative inline-flex h-5 w-9 items-center rounded-full">
<span
className={
(worktreeResetOn
? 'bg-emerald-500'
: 'bg-muted-foreground/30') +
' absolute inset-0 rounded-full transition-colors'
}
/>
<span
className={
(worktreeResetOn
? 'translate-x-5'
: 'translate-x-1') +
' pointer-events-none relative inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform'
}
/>
</div>
</div>
{worktreeResetOn && (
<>
<p className="mt-2 text-xs text-muted-foreground">
Your worktree will be restored to this commit.
</p>
<div className="mt-1 flex items-center gap-2 min-w-0">
<GitCommit className="h-3.5 w-3.5 text-muted-foreground" />
{short && (
<span className="font-mono text-xs px-2 py-0.5 rounded bg-muted">
{short}
</span>
)}
{targetSubject && (
<span className="text-muted-foreground break-words whitespace-normal">
{targetSubject}
</span>
)}
</div>
{((isLinear &&
commitsToReset !== null &&
commitsToReset > 0) ||
uncommittedCount > 0 ||
untrackedCount > 0) && (
<ul className="mt-2 space-y-1 text-xs text-muted-foreground list-disc pl-5">
{isLinear &&
commitsToReset !== null &&
commitsToReset > 0 && (
<li>
Roll back {commitsToReset} commit
{commitsToReset === 1 ? '' : 's'} from
current HEAD.
</li>
)}
{uncommittedCount > 0 && (
<li>
Discard {uncommittedCount} uncommitted change
{uncommittedCount === 1 ? '' : 's'}.
</li>
)}
{untrackedCount > 0 && (
<li>
{untrackedCount} untracked file
{untrackedCount === 1 ? '' : 's'} present (not
affected by reset).
</li>
)}
</ul>
)}
</>
)}
</div>
</div>
)}
{needGitReset && !canGitReset && (
<div
className={
forceReset && worktreeResetOn
? 'flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/10 p-3'
: 'flex items-start gap-3 rounded-md border p-3'
}
>
<AlertTriangle className="h-4 w-4 text-destructive mt-0.5" />
<div className="text-sm min-w-0 w-full break-words">
<p className="font-medium text-destructive">
Reset worktree
</p>
<div
className={`mt-2 w-full flex items-center select-none cursor-pointer`}
role="switch"
onClick={() => {
setWorktreeResetOn((on) => {
if (forceReset) return !on; // free toggle when forced
// Without force, only allow explicitly disabling reset
return false;
});
}}
>
<div className="text-xs text-muted-foreground">
{forceReset
? worktreeResetOn
? 'Enabled'
: 'Disabled'
: 'Disabled (uncommitted changes detected)'}
</div>
<div className="ml-auto relative inline-flex h-5 w-9 items-center rounded-full">
<span
className={
(worktreeResetOn && forceReset
? 'bg-emerald-500'
: 'bg-muted-foreground/30') +
' absolute inset-0 rounded-full transition-colors'
}
/>
<span
className={
(worktreeResetOn && forceReset
? 'translate-x-5'
: 'translate-x-1') +
' pointer-events-none relative inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform'
}
/>
</div>
</div>
<div
className="mt-2 w-full flex items-center cursor-pointer select-none"
role="switch"
onClick={() => {
setForceReset((v) => {
const next = !v;
if (next) setWorktreeResetOn(true);
return next;
});
}}
>
<div className="text-xs font-medium text-destructive">
Force reset (discard uncommitted changes)
</div>
<div className="ml-auto relative inline-flex h-5 w-9 items-center rounded-full">
<span
className={
(forceReset
? 'bg-destructive'
: 'bg-muted-foreground/30') +
' absolute inset-0 rounded-full transition-colors'
}
/>
<span
className={
(forceReset ? 'translate-x-5' : 'translate-x-1') +
' pointer-events-none relative inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform'
}
/>
</div>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{forceReset
? 'Uncommitted changes will be discarded.'
: 'Uncommitted changes present. Turn on Force reset or commit/stash to proceed.'}
</p>
{short && (
<>
<p className="mt-2 text-xs text-muted-foreground">
Your worktree will be restored to this commit.
</p>
<div className="mt-1 flex items-center gap-2 min-w-0">
<GitCommit className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-xs px-2 py-0.5 rounded bg-muted">
{short}
</span>
{targetSubject && (
<span className="text-muted-foreground break-words whitespace-normal">
{targetSubject}
</span>
)}
</div>
</>
)}
</div>
</div>
)}
{!hasLater && !needGitReset && (
<div className="flex items-start gap-3 rounded-md border border-green-300/60 bg-green-50/70 p-3">
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5" />
<div className="text-sm min-w-0 w-full break-words">
<p className="font-medium text-green-700 mb-2">
No resets required
</p>
<p className="mt-0.5">
You are already at this checkpoint. Retrying will start
a new run from here.
</p>
</div>
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button
variant="destructive"
disabled={
// Disable when uncommitted changes present and user hasn't enabled force
// or explicitly disabled worktree reset.
(hasRisk && worktreeResetOn && needGitReset && !forceReset) ||
false
}
onClick={handleConfirm}
>
Retry
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
);