* Excellent! ✅ All tasks completed successfully. Let me create a summary document: ## ✅ Typesafe Dialogs Refactoring Complete ### What Was Changed **1. New Typesafe Modal System** ([lib/modals.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/src/lib/modals.ts)) - Created `defineModal<ResultType>(component)` to brand dialogs with return types - Created `showModal(modal, props)` with full type inference for props and return values - Added typed `hideModal()` and `removeModal()` helpers **2. Modals Registry** ([components/dialogs/index.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/src/components/dialogs/index.ts)) - Exported `Modals` object with all 27 dialogs - Each modal properly typed with its result type - Autocomplete available via `Modals.*` **3. ESLint Enforcement** ([frontend/.eslintrc.cjs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/.eslintrc.cjs)) - Blocks direct `NiceModal.show()` usage - Blocks `NiceModal.register()` usage - Restricts `NiceModal` imports to lib/modals.ts, App.tsx, and dialog files **4. Removed String Registrations** ([main.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/src/main.tsx)) - Deleted 27 `NiceModal.register('string-id', Component)` calls - Dialogs now register automatically when used **5. Migrated All Call Sites** (14+ files) - Replaced `NiceModal.show('string-id', props)` → `showModal(Modals.X, props)` - All props and return types now fully typed - Legacy helper functions maintained for backward compatibility ### Usage Examples ```typescript // Before (not type-safe): const result = await NiceModal.show('onboarding'); // ❌ No autocomplete, no type checking, string can be misspelled // After (fully type-safe): const result = await showModal(Modals.Onboarding, {}); // ✅ result is typed as OnboardingResult // ✅ Props are checked at compile time // ✅ Autocomplete for modal names ``` ### Verification - ✅ **Type check passes**: `pnpm run check` - ✅ **Lint passes**: 0 errors (113 warnings, none related to this refactor) - ✅ **ESLint enforces pattern**: Prevents future regressions The refactoring is complete and all code is now fully typesafe! * Cleanup script changes for task attempt e9a36404-ba8e-4c84-9cca-1a1b7d68fdf7 * incremental * ## ✅ Component-Based Registration Complete! Successfully eliminated the centralized `Modals` registry and migrated to component-based dialog registration. ### What Changed **1. Each dialog now brands itself** (30 dialog files) - Added `defineModal<ResultType>()` at the source - Example: `export const FolderPickerDialog = defineModal<string | null>(FolderPickerDialogImpl)` **2. Removed centralized bottleneck** - Deleted `Modals` registry object from [components/dialogs/index.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/src/components/dialogs/index.ts) - Removed 100+ lines of duplication - index.ts now just re-exports for convenience **3. Fixed circular dependency** - Removed legacy helpers from [lib/modals.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/src/lib/modals.ts) - lib/modals.ts no longer imports from components **4. Updated all call sites** (24 files) - Changed from `showModal(Modals.X, props)` → `showModal(XDialog, props)` - Added direct component imports - Guided by TypeScript compiler errors ### Benefits Achieved ✅ **No duplication** - dialogs defined once in their own files ✅ **No circular deps** - clean import hierarchy ✅ **Easy to add dialogs** - just brand and use ✅ **Still fully typesafe** - props and return types inferred ✅ **Clear imports** - see exactly which dialogs are used ✅ **Compiler verified** - `pnpm run check` passes ✅ **Lint clean** - 0 errors * Cleanup script changes for task attempt e9a36404-ba8e-4c84-9cca-1a1b7d68fdf7 * Refactor dialog calling (vibe-kanban 8586934a) Instead of `showModal(CreatePRDialog)` it should be `CreatePRDialog.show()`, also we should add the same for `.hide()`. We should enforce the pattern using eslint, then use the lint errors to guide where to refactor. * reset merge conflicted dialogs to main * Fix dialog errors * fmt
392 lines
16 KiB
TypeScript
392 lines
16 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, GitCommit } from 'lucide-react';
|
|
import NiceModal, { useModal } from '@ebay/nice-modal-react';
|
|
import { defineModal } from '@/lib/modals';
|
|
|
|
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;
|
|
};
|
|
|
|
const RestoreLogsDialogImpl = 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 flex-1 min-w-0 break-words">
|
|
{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 flex-wrap 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 flex-1 min-w-0 max-w-full">
|
|
{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 flex-1 min-w-0 break-words">
|
|
{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 flex-1 min-w-0 break-words">
|
|
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 flex-wrap 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 flex-1 min-w-0 max-w-full">
|
|
{targetSubject}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
);
|
|
|
|
export const RestoreLogsDialog = defineModal<
|
|
RestoreLogsDialogProps,
|
|
RestoreLogsDialogResult
|
|
>(RestoreLogsDialogImpl);
|