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
215 changes: 175 additions & 40 deletions apps/web/app/routes/ws/deployments/_components/plans/PlanDiffDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,82 +1,217 @@
import { useState } from "react";
import type { RouterOutputs } from "@ctrlplane/trpc";
import { useEffect, useState } from "react";
import { DiffEditor } from "@monaco-editor/react";

import { trpc } from "~/api/trpc";
import { useTheme } from "~/components/ThemeProvider";
import { Badge } from "~/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Label } from "~/components/ui/label";
import { Switch } from "~/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";

type PlanDiffDialogProps = {
deploymentId: string;
resultId: string | undefined;
title: string;
open: boolean;
initialTab?: TopTab;
onOpenChange: (open: boolean) => void;
};

type TopTab = "diff" | "validations";
type DiffView = "split" | "unified";

type Validation =
RouterOutputs["deployment"]["plans"]["resultValidations"][number];

function ValidationsTab({
deploymentId,
resultId,
open,
}: {
deploymentId: string;
resultId: string;
open: boolean;
}) {
const query = trpc.deployment.plans.resultValidations.useQuery(
{ deploymentId, resultId },
{ enabled: open },
);

if (query.isLoading)
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading validations...
</div>
);

const validations = query.data ?? [];
if (validations.length === 0)
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No validations evaluated for this result
</div>
);

return (
<div className="h-full overflow-auto p-4">
<ul className="space-y-3">
{validations.map((v) => (
<ValidationItem key={v.id} validation={v} />
))}
</ul>
</div>
);
}

function ValidationItem({ validation }: { validation: Validation }) {
return (
<li className="rounded-md border p-3">
<div className="flex items-center gap-2">
<Badge variant={validation.passed ? "secondary" : "destructive"}>
{validation.passed ? "Passed" : "Failed"}
</Badge>
<span className="font-medium">{validation.ruleName}</span>
</div>
{validation.ruleDescription && (
<p className="mt-1 text-sm text-muted-foreground">
{validation.ruleDescription}
</p>
)}
{validation.violations.length > 0 && (
<ul className="mt-2 space-y-1 text-sm">
{validation.violations.map((violation, i) => (
<li key={i} className="font-mono text-red-600 dark:text-red-400">
Comment on lines +89 to +90
{violation.message}
</li>
))}
</ul>
)}
</li>
);
}

function DiffTab({
deploymentId,
resultId,
open,
view,
}: {
deploymentId: string;
resultId: string;
open: boolean;
view: DiffView;
}) {
const { theme } = useTheme();

const diffQuery = trpc.deployment.plans.resultDiff.useQuery(
{ deploymentId, resultId },
{ enabled: open },
);

if (diffQuery.isLoading)
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading diff...
</div>
);

if (diffQuery.data == null)
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No diff available
</div>
);

return (
<DiffEditor
height="100%"
language="yaml"
theme={theme === "dark" ? "vs-dark" : "vs"}
original={diffQuery.data.current}
modified={diffQuery.data.proposed}
options={{
readOnly: true,
renderSideBySide: view === "split",
minimap: { enabled: true },
scrollBeyondLastLine: false,
automaticLayout: true,
hideUnchangedRegions: {
enabled: true,
contextLineCount: 3,
minimumLineCount: 3,
revealLineCount: 20,
},
}}
/>
);
}

export function PlanDiffDialog({
deploymentId,
resultId,
title,
open,
initialTab = "diff",
onOpenChange,
}: PlanDiffDialogProps) {
const [view, setView] = useState<DiffView>("split");
const { theme } = useTheme();
const [tab, setTab] = useState<TopTab>(initialTab);
const [view, setView] = useState<DiffView>("unified");

const diffQuery = trpc.deployment.plans.resultDiff.useQuery(
{ deploymentId, resultId: resultId ?? "" },
{ enabled: open && resultId != null },
);
useEffect(() => {
if (open) setTab(initialTab);
}, [open, initialTab]);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[90vh] w-[95vw] max-w-[95vw] flex-col p-0 sm:max-w-[95vw]">
<DialogHeader className="flex-row items-center justify-between border-b p-4 pr-12">
<DialogTitle>{title}</DialogTitle>
<Tabs value={view} onValueChange={(v) => setView(v as DiffView)}>
<TabsList>
<TabsTrigger value="split">Split</TabsTrigger>
<TabsTrigger value="unified">Unified</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-4">
{tab === "diff" && (
<div className="flex items-center gap-2">
<Switch
id="diff-split"
checked={view === "split"}
onCheckedChange={(checked) =>
setView(checked ? "split" : "unified")
}
/>
<Label
htmlFor="diff-split"
className="text-xs text-muted-foreground"
>
Split
</Label>
</div>
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as TopTab)}>
<TabsList>
<TabsTrigger value="diff">Diff</TabsTrigger>
<TabsTrigger value="validations">Validations</TabsTrigger>
</TabsList>
</Tabs>
</div>
</DialogHeader>
<div className="min-h-0 flex-1">
{diffQuery.isLoading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading diff...
</div>
) : diffQuery.data == null ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No diff available
</div>
{resultId == null ? null : tab === "diff" ? (
<DiffTab
deploymentId={deploymentId}
resultId={resultId}
open={open}
view={view}
/>
) : (
<DiffEditor
height="100%"
language="yaml"
theme={theme === "dark" ? "vs-dark" : "vs"}
original={diffQuery.data.current}
modified={diffQuery.data.proposed}
options={{
readOnly: true,
renderSideBySide: view === "split",
minimap: { enabled: true },
scrollBeyondLastLine: false,
automaticLayout: true,
hideUnchangedRegions: {
enabled: true,
contextLineCount: 3,
minimumLineCount: 3,
revealLineCount: 20,
},
}}
<ValidationsTab
deploymentId={deploymentId}
resultId={resultId}
open={open}
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RouterOutputs } from "@ctrlplane/trpc";
import { useState } from "react";
import { FileText } from "lucide-react";
import { Link, useParams } from "react-router";

Expand Down Expand Up @@ -77,6 +78,38 @@ function ChangesCell({ result }: { result: Result }) {
return <span className="text-muted-foreground">—</span>;
}

function ValidationsCell({
validations,
onClick,
}: {
validations: Result["validations"];
onClick: () => void;
}) {
if (validations.total === 0)
return <span className="text-muted-foreground">—</span>;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="flex items-center gap-2 text-xs hover:underline"
>
{validations.failed > 0 && (
<span className="text-red-600 dark:text-red-400">
{validations.failed} failed
</span>
)}
{validations.passed > 0 && (
<span className="text-muted-foreground">
{validations.passed} passed
</span>
)}
</button>
);
}

function ResultsTableHeader() {
return (
<TableHeader>
Expand All @@ -86,23 +119,31 @@ function ResultsTableHeader() {
<TableHead className="font-medium">Agent</TableHead>
<TableHead className="font-medium">Status</TableHead>
<TableHead className="font-medium">Changes</TableHead>
<TableHead className="font-medium">Validations</TableHead>
</TableRow>
</TableHeader>
);
}

function ResultsTableRow({
result,
onViewDiff,
onOpenResult,
}: {
result: Result;
onViewDiff: (resultId: string) => void;
onOpenResult: (resultId: string, tab?: "diff" | "validations") => void;
}) {
const isClickable = result.hasChanges === true;
const hasChanges = result.hasChanges === true;
const hasValidations = result.validations.total > 0;
const isClickable = hasChanges || hasValidations;
const defaultTab = hasChanges ? "diff" : "validations";
return (
<TableRow
className={cn("hover:bg-muted/50", isClickable && "cursor-pointer")}
onClick={isClickable ? () => onViewDiff(result.resultId) : undefined}
onClick={
isClickable
? () => onOpenResult(result.resultId, defaultTab)
: undefined
}
Comment on lines +136 to +146
Comment on lines 140 to +146
Comment on lines 140 to +146
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make clickable rows keyboard-accessible.

onClick on <TableRow> makes this interaction pointer-only. Add keyboard activation (Enter/Space) and focusability for isClickable rows.

♿ Suggested fix
     <TableRow
       className={cn("hover:bg-muted/50", isClickable && "cursor-pointer")}
+      role={isClickable ? "button" : undefined}
+      tabIndex={isClickable ? 0 : undefined}
       onClick={
         isClickable ? () => onOpenResult(result.resultId, "diff") : undefined
       }
+      onKeyDown={
+        isClickable
+          ? (e) => {
+              if (e.key === "Enter" || e.key === " ") {
+                e.preventDefault();
+                onOpenResult(result.resultId, "diff");
+              }
+            }
+          : undefined
+      }
     >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<TableRow
className={cn("hover:bg-muted/50", isClickable && "cursor-pointer")}
onClick={isClickable ? () => onViewDiff(result.resultId) : undefined}
onClick={
isClickable ? () => onOpenResult(result.resultId, "diff") : undefined
}
<TableRow
className={cn("hover:bg-muted/50", isClickable && "cursor-pointer")}
role={isClickable ? "button" : undefined}
tabIndex={isClickable ? 0 : undefined}
onClick={
isClickable ? () => onOpenResult(result.resultId, "diff") : undefined
}
onKeyDown={
isClickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenResult(result.resultId, "diff");
}
}
: undefined
}
>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/routes/ws/deployments/page`.$deploymentId.plans.$planId.tsx
around lines 138 - 142, The TableRow currently uses onClick for navigation which
is pointer-only; make rows keyboard-accessible by, when isClickable is true,
adding focusability (e.g., tabIndex={0}), a proper semantic role (e.g.,
role="button" or adjust per semantics) and an onKeyDown handler that listens for
Enter and Space and calls onOpenResult(result.resultId, "diff") (for Space
ensure preventDefault to avoid scrolling). Update the TableRow props where
isClickable is used so it conditionally adds tabIndex, role, onKeyDown, and
keeps the existing onClick and cursor/focus styles so keyboard users can both
focus and activate the row.

>
<TableCell>{result.environment.name}</TableCell>
<TableCell>{result.resource.name}</TableCell>
Expand All @@ -113,6 +154,12 @@ function ResultsTableRow({
<TableCell>
<ChangesCell result={result} />
</TableCell>
<TableCell>
<ValidationsCell
validations={result.validations}
onClick={() => onOpenResult(result.resultId, "validations")}
/>
</TableCell>
</TableRow>
);
}
Expand Down Expand Up @@ -140,6 +187,7 @@ export default function DeploymentPlanDetail() {
const { deployment } = useDeployment();
const { planId } = useParams<{ planId: string }>();
const { resultId, openResult, closeResult } = usePlanResultParam();
const [initialTab, setInitialTab] = useState<"diff" | "validations">("diff");

const resultsQuery = trpc.deployment.plans.results.useQuery(
{ deploymentId: deployment.id, planId: planId! },
Expand All @@ -150,6 +198,14 @@ export default function DeploymentPlanDetail() {
const results = resultsQuery.data?.items ?? [];
const activeResult = results.find((r) => r.resultId === resultId);

const handleOpenResult = (
id: string,
tab: "diff" | "validations" = "diff",
) => {
setInitialTab(tab);
openResult(id);
};

return (
<>
<header className="flex h-16 shrink-0 items-center justify-between gap-2 border-b pr-4">
Expand Down Expand Up @@ -201,7 +257,7 @@ export default function DeploymentPlanDetail() {
<ResultsTableRow
key={r.resultId}
result={r}
onViewDiff={openResult}
onOpenResult={handleOpenResult}
/>
))}
</TableBody>
Expand All @@ -213,6 +269,7 @@ export default function DeploymentPlanDetail() {
resultId={resultId}
title={activeResult ? resultTitle(activeResult) : ""}
open={resultId != null}
initialTab={initialTab}
onOpenChange={(o) => {
if (!o) closeResult();
}}
Expand Down
Loading
Loading