Skip to content
646 linesCodeBlameRaw
1/**
2 * The security suite's pieces, shared by a project's Security sections and
3 * the workspace's: the activation prompt, filters, code scanning alerts,
4 * the trend chart and coverage table, the custom pattern editor, and the
5 * bypass and "Fix with g1t" forms. Each form posts an `intent` to the page
6 * it is on.
7 */
8import { Bot, CircleCheck, CircleDot, CircleSlash, FileCode2, Lock, ShieldCheck, Sparkles } from "lucide-react";
9import { type ReactNode, useEffect, useState } from "react";
10import { Link, useFetcher } from "react-router";
11
12import {
13 type AlertState,
14 BYPASS_REASONS,
15 type CodeAlert,
16 type DryRun,
17 type RepoCoverage,
18 type SavedPattern,
19 type Severity,
20 type SeverityCounts,
21 type TrendPoint,
22} from "@g1t/contracts";
23
24import { total, trendMax } from "../lib/security-suite";
25import { SeverityBadge } from "./security";
26import { ButtonLink, TimeAgo } from "./ui";
27import { Badge } from "./ui/badge";
28import { Button } from "./ui/button";
29import { Card } from "./ui/card";
30import { Hint } from "./ui/hint";
31import { RadioGroup, RadioOption } from "./ui/radio-group";
32import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
33import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
34
35type Done = { ok: boolean; error?: string } | undefined;
36
37export const CARD = "rounded-xl border border-line bg-surface";
38export const LIST = "divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface";
39const INPUT =
40 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
41const SMALL_BUTTON =
42 "rounded-md border border-line px-2.5 py-1 text-xs font-medium text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-50";
43/** A section's title, what it is for, and what can be done there. */
44export function SectionHeader({ title, about, actions }: { title: string; about: ReactNode; actions?: ReactNode }) {
45 return (
46 <div className="flex flex-wrap items-start justify-between gap-4">
47 <div className="min-w-0 max-w-2xl">
48 <h2 className="text-xl font-semibold tracking-tight">{title}</h2>
49 <p className="mt-1.5 text-sm text-muted">{about}</p>
50 </div>
51 {actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
52 </div>
53 );
54}
55
56/**
57 * What a private repository's paid feature needs: the g1t plan, which
58 * Security and quality comes with at no price of its own, and who can
59 * start it. No pressure: what stays free is said too.
60 */
61export function ActivationPrompt({
62 workspace,
63 feature,
64 monthlyCents,
65 isOwner,
66}: {
67 workspace: string;
68 feature: string;
69 /** The g1t plan's monthly price, from billing; null when it could not be read. */
70 monthlyCents: number | null;
71 isOwner: boolean;
72}) {
73 const price = monthlyCents == null ? null : `$${(monthlyCents / 100).toFixed(monthlyCents % 100 ? 2 : 0)} a month`;
74 return (
75 <div className={`${CARD} flex flex-col gap-4 p-5 sm:flex-row sm:items-start`}>
76 <Lock size={18} className="mt-0.5 shrink-0 text-accent" />
77 <div className="min-w-0 grow">
78 <p className="font-medium">{feature} on private repositories comes with the g1t plan</p>
79 <p className="mt-1.5 text-sm text-muted">
80 Security and quality has no price of its own. With the plan{price ? ` (${price} for the workspace)` : ""}, custom patterns,
81 validity checks, delegated bypass, code scanning, dependency review and the security overview are on for every private
82 repository in {workspace}, and their scans are charged at cost plus 20%, like everything g1t runs. Fixes by g1t's agent
83 are charged as agent usage. Public repositories have all of it free, and secret scanning, push protection, vulnerability
84 alerts and security updates stay free everywhere.
85 </p>
86 <div className="mt-3 flex flex-wrap items-center gap-3 text-sm">
87 {isOwner ? (
88 <ButtonLink to={`/${workspace}/-/billing`}>
89 Start the plan
90 </ButtonLink>
91 ) : (
92 <span className="text-muted">An owner of {workspace} can start the plan in Billing.</span>
93 )}
94 <a href="https://docs.g1t.sh/guides/security/pricing/" className="text-muted underline underline-offset-2 hover:text-fg">
95 What's free and what's paid
96 </a>
97 </div>
98 </div>
99 </div>
100 );
101}
102
103/** A labelled select that changes a filter in the address. */
104export function FilterSelect({
105 label,
106 value,
107 options,
108 onChange,
109}: {
110 label: string;
111 value: string;
112 options: [string, string][];
113 onChange: (value: string) => void;
114}) {
115 return (
116 <label className="flex min-w-0 flex-col gap-1 text-xs text-muted">
117 {label}
118 <Select value={value} onValueChange={onChange}>
119 <SelectTrigger size="sm" className="min-w-36" aria-label={label}>
120 <SelectValue />
121 </SelectTrigger>
122 <SelectContent>
123 {options.map(([option, text]) => (
124 <SelectItem key={option} value={option}>
125 {text}
126 </SelectItem>
127 ))}
128 </SelectContent>
129 </Select>
130 </label>
131 );
132}
133
134const STATE_ICON: Record<AlertState, ReactNode> = {
135 open: <CircleDot size={14} />,
136 dismissed: <CircleSlash size={14} />,
137 fixed: <CircleCheck size={14} />,
138};
139
140/** A code scanning alert in a list. */
141export function CodeAlertItem({ alert, base }: { alert: CodeAlert; base: string }) {
142 const where = alert.path ? `${alert.path}${alert.startLine ? `:${alert.startLine}` : ""}` : null;
143 return (
144 <li className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-start">
145 <span className={`mt-0.5 hidden shrink-0 sm:block ${alert.state === "open" ? "text-warn" : "text-muted"}`}>{STATE_ICON[alert.state]}</span>
146 <div className="min-w-0 grow">
147 <div className="flex flex-wrap items-center gap-2">
148 <Link to={`${base}/security/code-scanning/${alert.number}`} className="min-w-0 text-sm font-medium break-words hover:underline">
149 {alert.ruleName && alert.ruleName !== alert.ruleId ? alert.ruleName : shortRule(alert.ruleId)}
150 </Link>
151 <SeverityBadge severity={alert.severity} />
152 {alert.issue != null && (
153 <Link to={`${base}/issues/${alert.issue}`}>
154 <Badge tone="accent">
155 <Bot size={11} /> g1t on #{alert.issue}
156 </Badge>
157 </Link>
158 )}
159 </div>
160 <p className="mt-1 line-clamp-2 text-xs text-muted">{alert.message}</p>
161 <p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-faint">
162 <span>#{alert.number}</span>
163 <span>{alert.tool}</span>
164 {where && <span className="truncate font-mono">{where}</span>}
165 <span>
166 {alert.state === "fixed" && alert.fixedAt ? (
167 <>
168 fixed <TimeAgo at={alert.fixedAt} />
169 </>
170 ) : (
171 <>
172 found <TimeAgo at={alert.createdAt} />
173 </>
174 )}
175 </span>
176 </p>
177 </div>
178 </li>
179 );
180}
181
182/** The last part of a dotted rule id: Semgrep's are long. */
183export function shortRule(ruleId: string): string {
184 const parts = ruleId.split(".");
185 return parts.length > 2 ? parts[parts.length - 1] : ruleId;
186}
187
188/** Open counts as compact words: "3 critical · 1 high". */
189export function countsLine(counts: SeverityCounts): string {
190 const parts = (["critical", "high", "medium", "low"] as Severity[]).filter((s) => counts[s] > 0).map((s) => `${counts[s]} ${s}`);
191 return parts.length ? parts.join(" · ") : "none open";
192}
193
194// The trend's three series, each its own hue, validated for the dark
195// surface (dataviz: categorical slots 1–3, dark steps).
196const SERIES = [
197 { key: "secretScanning", label: "Secrets", color: "#3987e5" },
198 { key: "codeScanning", label: "Code scanning", color: "#d95926" },
199 { key: "vulnerability", label: "Vulnerabilities", color: "#199e70" },
200] as const;
201
202/** Open alerts by type each day, as stacked bars with a legend and a table. */
203export function TrendChart({ points }: { points: TrendPoint[] }) {
204 const [hover, setHover] = useState<number | null>(null);
205 const max = trendMax(points);
206 const width = 640;
207 const height = 140;
208 const gap = 2;
209 const bar = Math.max(2, width / Math.max(points.length, 1) - gap);
210 const shown = hover != null ? points[hover] : points[points.length - 1];
211 return (
212 <figure className={`${CARD} p-4`}>
213 <figcaption className="flex flex-wrap items-baseline justify-between gap-2">
214 <span className="text-sm font-medium">Open alerts, by day</span>
215 <span className="text-xs text-muted" aria-live="polite">
216 {shown
217 ? `${shown.day}: ${SERIES.map((s) => `${shown[s.key]} ${s.label.toLowerCase()}`).join(", ")}`
218 : "No days yet"}
219 </span>
220 </figcaption>
221 <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted" aria-label="Legend">
222 {SERIES.map((series) => (
223 <li key={series.key} className="flex items-center gap-1.5">
224 <span aria-hidden="true" className="size-2.5 rounded-sm" style={{ background: series.color }} />
225 {series.label}
226 </li>
227 ))}
228 </ul>
229 <svg
230 viewBox={`0 0 ${width} ${height}`}
231 preserveAspectRatio="none"
232 className="mt-3 h-36 w-full"
233 role="img"
234 aria-label="Open alerts by type each day; the table below has the numbers"
235 onMouseLeave={() => setHover(null)}
236 >
237 <line x1={0} x2={width} y1={height - 0.5} y2={height - 0.5} stroke="currentColor" className="text-line" />
238 {points.map((point, at) => {
239 const x = at * (bar + gap);
240 let y = height;
241 return (
242 <g key={point.day} onMouseEnter={() => setHover(at)}>
243 {/* A hit target the full height of the day. */}
244 <rect x={x} y={0} width={bar + gap} height={height} fill="transparent" />
245 {SERIES.map((series) => {
246 const value = point[series.key];
247 if (!value) return null;
248 const h = Math.max(1, (value / max) * (height - 8));
249 y -= h;
250 const rect = <rect key={series.key} x={x} y={y} width={bar} height={Math.max(0, h - gap)} rx={Math.min(2, bar / 2)} fill={series.color} opacity={hover == null || hover === at ? 1 : 0.55} />;
251 return rect;
252 })}
253 <title>{`${point.day}: ${SERIES.map((s) => `${point[s.key]} ${s.label.toLowerCase()}`).join(", ")}`}</title>
254 </g>
255 );
256 })}
257 </svg>
258 <details className="mt-2">
259 <summary className="cursor-pointer text-xs text-muted hover:text-fg">Show as a table</summary>
260 <div className="mt-2 max-h-56 overflow-auto">
261 <table className="w-full text-xs tabular-nums">
262 <thead className="text-left text-muted">
263 <tr>
264 <th className="py-1 font-medium">Day</th>
265 {SERIES.map((series) => (
266 <th key={series.key} className="py-1 text-right font-medium">
267 {series.label}
268 </th>
269 ))}
270 </tr>
271 </thead>
272 <tbody>
273 {[...points].reverse().map((point) => (
274 <tr key={point.day} className="border-t border-line">
275 <td className="py-1">{point.day}</td>
276 {SERIES.map((series) => (
277 <td key={series.key} className="py-1 text-right">
278 {point[series.key]}
279 </td>
280 ))}
281 </tr>
282 ))}
283 </tbody>
284 </table>
285 </div>
286 </details>
287 </figure>
288 );
289}
290
291function On({ on, children }: { on: boolean; children?: ReactNode }) {
292 return on ? (
293 <span className="inline-flex items-center gap-1 text-success">
294 <CircleCheck size={13} aria-hidden="true" />
295 {children ?? "On"}
296 </span>
297 ) : (
298 <span className="text-faint">{children ? children : "Off"}</span>
299 );
300}
301
302/** Which repository has which feature on, and what is open in each. */
303export function CoverageTable({ repos, owner }: { repos: RepoCoverage[]; owner: string }) {
304 if (repos.length === 0) {
305 return (
306 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-sm text-muted">
307 <p>
308 No repository has been scanned yet. Each one is scanned on its next push to its default branch, or when its Security page
309 is first opened.
310 </p>
311 </Card>
312 );
313 }
314 return (
315 <div className={CARD}>
316 <Table className="min-w-[46rem]">
317 <TableHeader>
318 <TableRow>
319 <TableHead className="px-4">Repository</TableHead>
320 <TableHead>Open</TableHead>
321 <TableHead>Push protection</TableHead>
322 <TableHead>Custom patterns</TableHead>
323 <TableHead>Code scanning</TableHead>
324 <TableHead>Dependency review</TableHead>
325 <TableHead>Security updates</TableHead>
326 </TableRow>
327 </TableHeader>
328 <TableBody>
329 {repos.map((repo) => {
330 const open = total(repo.secrets) + total(repo.code) + total(repo.vulnerabilities);
331 const critical = repo.secrets.critical + repo.code.critical + repo.vulnerabilities.critical;
332 const high = repo.secrets.high + repo.code.high + repo.vulnerabilities.high;
333 return (
334 <TableRow key={repo.repoId}>
335 <TableCell className="px-4">
336 <Link to={`/${owner}/${repo.name}/security`} className="font-mono text-sm font-medium hover:underline">
337 {repo.name}
338 </Link>
339 {repo.private && <span className="ml-2 text-xs text-faint">private</span>}
340 </TableCell>
341 <TableCell className="text-xs whitespace-nowrap">
342 {open === 0 ? (
343 <span className="inline-flex items-center gap-1 text-success">
344 <ShieldCheck size={13} /> None
345 </span>
346 ) : (
347 <span className="flex flex-wrap gap-1">
348 {critical > 0 && <Badge tone="danger">{critical} critical</Badge>}
349 {high > 0 && <Badge tone="warn">{high} high</Badge>}
350 {open - critical - high > 0 && <Badge>{open - critical - high} other</Badge>}
351 </span>
352 )}
353 </TableCell>
354 <TableCell className="text-xs">
355 <On on>On</On>
356 </TableCell>
357 <TableCell className="text-xs tabular-nums">
358 <On on={repo.customPatterns > 0}>{repo.customPatterns > 0 ? `${repo.customPatterns}` : "None"}</On>
359 </TableCell>
360 <TableCell className="text-xs">
361 {repo.codeScanningAt ? (
362 <span className="text-success">
363 <TimeAgo at={repo.codeScanningAt} />
364 </span>
365 ) : (
366 <span className="text-faint">Not set up</span>
367 )}
368 </TableCell>
369 <TableCell className="text-xs">
370 <On on={repo.dependencyReview && repo.lockfiles > 0}>{repo.lockfiles === 0 ? "No lockfiles" : undefined}</On>
371 </TableCell>
372 <TableCell className="text-xs">
373 <On on={repo.securityUpdates} />
374 </TableCell>
375 </TableRow>
376 );
377 })}
378 </TableBody>
379 </Table>
380 </div>
381 );
382}
383
384/** "Fix with g1t": an issue for g1t, as the person asking. */
385export function FixWithG1t({ id, action, issue, base }: { id: string; action: string; issue: number | null; base: string }) {
386 const fetcher = useFetcher<{ ok: boolean; error?: string; issue?: number; message?: string | null }>();
387 const opened = fetcher.data?.issue ?? issue;
388 if (opened != null) {
389 return (
390 <Link to={`${base}/issues/${opened}`} className={`${SMALL_BUTTON} inline-flex items-center gap-1.5`}>
391 <Bot size={13} /> g1t is on #{opened}
392 </Link>
393 );
394 }
395 return (
396 <span className="flex flex-col items-end gap-1">
397 <Hint label="Opens an issue assigned to g1t; its run is charged as agent usage">
398 <button
399 type="button"
400 disabled={fetcher.state !== "idle"}
401 onClick={() => fetcher.submit({ intent: "fix", id }, { method: "post", action })}
402 className={`${SMALL_BUTTON} inline-flex items-center gap-1.5`}
403 >
404 <Sparkles size={13} />
405 {fetcher.state !== "idle" ? "Opening…" : "Fix with g1t"}
406 </button>
407 </Hint>
408 {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
409 </span>
410 );
411}
412
413/** Pushing past push protection with a reason, or asking to. */
414export function BypassForm({ id, action, request }: { id: string; action: string; request: boolean }) {
415 const fetcher = useFetcher<Done & { requested?: boolean }>();
416 const [reason, setReason] = useState("");
417 if (fetcher.data?.ok) {
418 return (
419 <p className="rounded-md border border-success/30 bg-success/5 px-3 py-2 text-sm text-success">
420 {fetcher.data.requested
421 ? "Asked. The workspace's owners and the repository's admins were told; push again once one approves."
422 : "Bypassed. Push again and it goes through."}
423 </p>
424 );
425 }
426 return (
427 <fetcher.Form method="post" action={action} className="space-y-3">
428 <input type="hidden" name="intent" value="bypass" />
429 <input type="hidden" name="id" value={id} />
430 <fieldset>
431 <legend className="text-xs font-medium text-muted">Why does it need to go through?</legend>
432 <RadioGroup name="reason" value={reason} onValueChange={setReason} required className="mt-2 gap-3">
433 {BYPASS_REASONS.map((option) => (
434 <RadioOption key={option.reason} value={option.reason} label={option.label} description={option.about} />
435 ))}
436 </RadioGroup>
437 </fieldset>
438 <label className="block">
439 <span className="mb-1.5 block text-xs font-medium text-muted">Comment (optional)</span>
440 <textarea name="comment" rows={2} maxLength={500} className={INPUT} placeholder="What a reviewer should know." />
441 </label>
442 <div className="flex flex-wrap items-center gap-3">
443 <Button type="submit" disabled={fetcher.state !== "idle" || !reason}>
444 {fetcher.state !== "idle" ? "Sending…" : request ? "Ask to bypass" : "Bypass push protection"}
445 </Button>
446 <span className="text-xs text-muted">Recorded with your name and reason, on the alert and in the audit log.</span>
447 </div>
448 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
449 </fetcher.Form>
450 );
451}
452
453export type PatternDraft = {
454 id?: string;
455 name: string;
456 pattern: string;
457 before: string;
458 after: string;
459 testStrings: string;
460 published: boolean;
461};
462
463/** Where a test string matched, the match marked. */
464function TestResult({ text, found }: { text: string; found: [number, number] | null }) {
465 if (!found) {
466 return (
467 <li className="flex items-start gap-2 font-mono text-xs break-all">
468 <CircleSlash size={13} className="mt-0.5 shrink-0 text-faint" />
469 <span className="text-muted">{text}</span>
470 </li>
471 );
472 }
473 const chars = [...text];
474 return (
475 <li className="flex items-start gap-2 font-mono text-xs break-all">
476 <CircleCheck size={13} className="mt-0.5 shrink-0 text-success" />
477 <span>
478 {chars.slice(0, found[0]).join("")}
479 <mark className="rounded-sm bg-accent/25 text-fg">{chars.slice(found[0], found[1]).join("")}</mark>
480 {chars.slice(found[1]).join("")}
481 </span>
482 </li>
483 );
484}
485
486/**
487 * Making or changing a custom pattern: its regular expression, what comes
488 * before and after it, test strings, a dry run over the default branch,
489 * and saving it as a draft or published.
490 */
491export function PatternEditor({ draft, action, onDone }: { draft: PatternDraft; action: string; onDone?: () => void }) {
492 const save = useFetcher<{ ok: boolean; error?: string; saved?: SavedPattern }>();
493 const dry = useFetcher<{ ok: boolean; error?: string; dryRun?: DryRun }>();
494 const [form, setForm] = useState(draft);
495 useEffect(() => {
496 if (save.state === "idle" && save.data?.ok && onDone) onDone();
497 }, [save.state, save.data, onDone]);
498 const set = (key: keyof PatternDraft) => (event: { target: { value: string } }) => setForm({ ...form, [key]: event.target.value });
499 const fields = (publish: boolean) => ({
500 intent: "save_pattern",
501 id: form.id ?? "",
502 name: form.name,
503 pattern: form.pattern,
504 before: form.before,
505 after: form.after,
506 testStrings: form.testStrings,
507 publish: String(publish),
508 });
509 const tests = form.testStrings.split("\n").filter((line) => line.trim());
510 const saved = save.data?.saved;
511 return (
512 <div className="space-y-4">
513 <div className="grid gap-4 sm:grid-cols-2">
514 <label className="block">
515 <span className="mb-1.5 block text-xs font-medium text-muted">Name</span>
516 <input value={form.name} onChange={set("name")} maxLength={100} placeholder="Acme API key" className={INPUT} />
517 </label>
518 <label className="block sm:col-span-2">
519 <span className="mb-1.5 block text-xs font-medium text-muted">Secret format (regular expression)</span>
520 <input
521 value={form.pattern}
522 onChange={set("pattern")}
523 maxLength={1000}
524 spellCheck={false}
525 placeholder="acme_[a-z0-9]{32}"
526 className={`${INPUT} font-mono`}
527 />
528 </label>
529 <label className="block">
530 <span className="mb-1.5 block text-xs font-medium text-muted">Before the secret (optional)</span>
531 <input value={form.before} onChange={set("before")} spellCheck={false} placeholder="\A|[^0-9A-Za-z]" className={`${INPUT} font-mono`} />
532 </label>
533 <label className="block">
534 <span className="mb-1.5 block text-xs font-medium text-muted">After the secret (optional)</span>
535 <input value={form.after} onChange={set("after")} spellCheck={false} placeholder="\z|[^0-9A-Za-z]" className={`${INPUT} font-mono`} />
536 </label>
537 <label className="block sm:col-span-2">
538 <span className="mb-1.5 block text-xs font-medium text-muted">Test strings, one a line</span>
539 <textarea value={form.testStrings} onChange={set("testStrings")} rows={3} spellCheck={false} className={`${INPUT} font-mono`} />
540 </label>
541 </div>
542 <p className="text-xs text-muted">
543 Patterns use the Rust regex syntax, which runs in time linear in the text: no look-around or back-references. Before and
544 after default to a line edge or a character that is not a letter or digit.
545 </p>
546 {saved && tests.length > 0 && (
547 <ul className="space-y-1">
548 {tests.map((text, at) => (
549 <TestResult key={at} text={text} found={saved.tests[at] ?? null} />
550 ))}
551 </ul>
552 )}
553 <div className="flex flex-wrap items-center gap-2">
554 <Button
555 type="button"
556 disabled={save.state !== "idle" || !form.name.trim() || !form.pattern.trim()}
557 onClick={() => save.submit(fields(true), { method: "post", action })}>
558 {save.state !== "idle" ? "Saving…" : form.published ? "Save" : "Publish"}
559 </Button>
560 <button
561 type="button"
562 disabled={save.state !== "idle" || !form.name.trim() || !form.pattern.trim()}
563 onClick={() => save.submit(fields(false), { method: "post", action })}
564 className={SMALL_BUTTON}
565 >
566 {form.published ? "Unpublish, keep as draft" : "Save as draft"}
567 </button>
568 <button
569 type="button"
570 disabled={dry.state !== "idle" || !form.pattern.trim()}
571 onClick={() => dry.submit({ intent: "dry_run", pattern: form.pattern, before: form.before, after: form.after }, { method: "post", action })}
572 className={SMALL_BUTTON}
573 >
574 {dry.state !== "idle" ? "Running…" : "Dry run"}
575 </button>
576 {form.id && (
577 <Button
578 type="button"
579 disabled={save.state !== "idle"}
580 onClick={() => {
581 if (confirm(`Delete "${form.name}"? The alerts it found stay.`)) save.submit({ intent: "delete_pattern", id: form.id ?? "" }, { method: "post", action });
582 }}
583 variant="destructive"
584 size="inline"
585 className="ml-auto px-2.5 py-1 text-xs"
586 >
587 Delete
588 </Button>
589 )}
590 </div>
591 {save.data?.error && <p className="text-sm text-danger">{save.data.error}</p>}
592 {save.data?.ok && (saved ? <p className="text-sm text-success">Saved{saved.pattern.state === "published" ? " and published: the history is scanned again for it" : " as a draft"}.</p> : <p className="text-sm text-success">Deleted. The alerts it found stay.</p>)}
593 {dry.data?.error && <p className="text-sm text-danger">{dry.data.error}</p>}
594 {dry.data?.dryRun && <DryRunResults dryRun={dry.data.dryRun} />}
595 </div>
596 );
597}
598
599function DryRunResults({ dryRun }: { dryRun: DryRun }) {
600 return (
601 <div className="space-y-3">
602 {dryRun.repos.map((repo) => (
603 <section key={repo.name} className={`${CARD} p-3`}>
604 <p className="text-sm">
605 <span className="font-mono font-medium">{repo.name}</span>
606 <span className="text-muted">
607 {" "}
608 · {repo.matches.length} {repo.matches.length === 1 ? "match" : "matches"} in {repo.filesScanned} files
609 {repo.truncated ? " (stopped early)" : ""}
610 </span>
611 </p>
612 {repo.matches.length > 0 && (
613 <ul className="mt-2 space-y-1">
614 {repo.matches.map((match, at) => (
615 <li key={at} className="font-mono text-xs break-all">
616 <span className="inline-flex items-center gap-1 text-fg-soft">
617 <FileCode2 size={12} />
618 {match.path}:{match.line}
619 </span>
620 <span className="text-faint"> {match.preview}</span>
621 </li>
622 ))}
623 </ul>
624 )}
625 </section>
626 ))}
627 </div>
628 );
629}
630
631/** Reads a pattern form's fields, as the pages' actions take them. */
632export function patternFields(form: FormData) {
633 const text = (key: string) => String(form.get(key) ?? "");
634 return {
635 id: text("id") || undefined,
636 name: text("name").trim(),
637 pattern: text("pattern"),
638 before: text("before").trim() || null,
639 after: text("after").trim() || null,
640 testStrings: text("testStrings")
641 .split("\n")
642 .map((line) => line.replace(/\r$/, ""))
643 .filter((line) => line.trim()),
644 publish: text("publish") === "true",
645 };
646}