Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

617 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 form. Each form posts an `intent` to the page
6 * it is on.
7 */
8import { Bot, CircleCheck, CircleDot, CircleSlash, FileCode2, Lock, ShieldCheck } 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 { RadioGroup, RadioOption } from "./ui/radio-group";
31import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
32import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
33import { wholeDollars } from "../lib/money";
34import { itemPath } from "../lib/refs";
35
36type Done = { ok: boolean; error?: string } | undefined;
37
38export const CARD = "rounded-xl border border-line bg-surface";
39export const LIST = "divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface";
40const INPUT =
41 "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";
42const SMALL_BUTTON =
43 "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";
44/** A section's title, what it is for, and what can be done there. */
45export function SectionHeader({ title, about, actions }: { title: string; about: ReactNode; actions?: ReactNode }) {
46 return (
47 <div className="flex flex-wrap items-start justify-between gap-4">
48 <div className="min-w-0 max-w-2xl">
49 <h2 className="text-xl font-semibold tracking-tight">{title}</h2>
50 <p className="mt-1.5 text-sm text-muted">{about}</p>
51 </div>
52 {actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
53 </div>
54 );
55}
56
57/**
58 * What a private repository's paid feature needs: the g1t plan, which
59 * Security and quality comes with at no price of its own, and who can
60 * start it. No pressure: what stays free is said too.
61 */
62export function ActivationPrompt({
63 workspace,
64 feature,
65 monthlyCents,
66 isOwner,
67}: {
68 workspace: string;
69 feature: string;
70 /** The g1t plan's monthly price, from billing; null when it could not be read. */
71 monthlyCents: number | null;
72 isOwner: boolean;
73}) {
74 const price = monthlyCents == null ? null : `${wholeDollars(monthlyCents * 10_000)} a month`;
75 return (
76 <div className={`${CARD} flex flex-col gap-4 p-5 sm:flex-row sm:items-start`}>
77 <Lock size={18} className="mt-0.5 shrink-0 text-accent" />
78 <div className="min-w-0 grow">
79 <p className="font-medium">{feature} on private repositories comes with the g1t plan</p>
80 <p className="mt-1.5 text-sm text-muted">
81 Security and quality has no price of its own. With the plan{price ? ` (${price} for the workspace)` : ""}, custom patterns,
82 validity checks, delegated bypass, code scanning, dependency review and the security overview are on for every private
83 repository in {workspace}, and their scans are charged at cost plus 20%, like everything g1t runs. 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 wrap-break-word hover:underline">
149 {alert.ruleName && alert.ruleName !== alert.ruleId ? alert.ruleName : shortRule(alert.ruleId)}
150 </Link>
151 <SeverityBadge severity={alert.severity} />
152 {alert.item != null && (
153 <Link to={itemPath(base.split("/")[1]!, alert.item)}>
154 <Badge tone="accent">
155 <Bot size={11} /> g1t on #{alert.item}
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-184">
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/** Pushing past push protection with a reason, or asking to. */
385export function BypassForm({ id, action, request }: { id: string; action: string; request: boolean }) {
386 const fetcher = useFetcher<Done & { requested?: boolean }>();
387 const [reason, setReason] = useState("");
388 if (fetcher.data?.ok) {
389 return (
390 <p className="rounded-md border border-success/30 bg-success/5 px-3 py-2 text-sm text-success">
391 {fetcher.data.requested
392 ? "Asked. The workspace's owners and the repository's admins were told; push again once one approves."
393 : "Bypassed. Push again and it goes through."}
394 </p>
395 );
396 }
397 return (
398 <fetcher.Form method="post" action={action} className="space-y-3">
399 <input type="hidden" name="intent" value="bypass" />
400 <input type="hidden" name="id" value={id} />
401 <fieldset>
402 <legend className="text-xs font-medium text-muted">Why does it need to go through?</legend>
403 <RadioGroup name="reason" value={reason} onValueChange={setReason} required className="mt-2 gap-3">
404 {BYPASS_REASONS.map((option) => (
405 <RadioOption key={option.reason} value={option.reason} label={option.label} description={option.about} />
406 ))}
407 </RadioGroup>
408 </fieldset>
409 <label className="block">
410 <span className="mb-1.5 block text-xs font-medium text-muted">Comment (optional)</span>
411 <textarea name="comment" rows={2} maxLength={500} className={INPUT} placeholder="What a reviewer should know." />
412 </label>
413 <div className="flex flex-wrap items-center gap-3">
414 <Button type="submit" disabled={fetcher.state !== "idle" || !reason}>
415 {fetcher.state !== "idle" ? "Sending…" : request ? "Ask to bypass" : "Bypass push protection"}
416 </Button>
417 <span className="text-xs text-muted">Recorded with your name and reason, on the alert and in the audit log.</span>
418 </div>
419 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
420 </fetcher.Form>
421 );
422}
423
424export type PatternDraft = {
425 id?: string;
426 name: string;
427 pattern: string;
428 before: string;
429 after: string;
430 testStrings: string;
431 published: boolean;
432};
433
434/** Where a test string matched, the match marked. */
435function TestResult({ text, found }: { text: string; found: [number, number] | null }) {
436 if (!found) {
437 return (
438 <li className="flex items-start gap-2 font-mono text-xs break-all">
439 <CircleSlash size={13} className="mt-0.5 shrink-0 text-faint" />
440 <span className="text-muted">{text}</span>
441 </li>
442 );
443 }
444 const chars = [...text];
445 return (
446 <li className="flex items-start gap-2 font-mono text-xs break-all">
447 <CircleCheck size={13} className="mt-0.5 shrink-0 text-success" />
448 <span>
449 {chars.slice(0, found[0]).join("")}
450 <mark className="rounded-sm bg-accent/25 text-fg">{chars.slice(found[0], found[1]).join("")}</mark>
451 {chars.slice(found[1]).join("")}
452 </span>
453 </li>
454 );
455}
456
457/**
458 * Making or changing a custom pattern: its regular expression, what comes
459 * before and after it, test strings, a dry run over the default branch,
460 * and saving it as a draft or published.
461 */
462export function PatternEditor({ draft, action, onDone }: { draft: PatternDraft; action: string; onDone?: () => void }) {
463 const save = useFetcher<{ ok: boolean; error?: string; saved?: SavedPattern }>();
464 const dry = useFetcher<{ ok: boolean; error?: string; dryRun?: DryRun }>();
465 const [form, setForm] = useState(draft);
466 useEffect(() => {
467 if (save.state === "idle" && save.data?.ok && onDone) onDone();
468 }, [save.state, save.data, onDone]);
469 const set = (key: keyof PatternDraft) => (event: { target: { value: string } }) => setForm({ ...form, [key]: event.target.value });
470 const fields = (publish: boolean) => ({
471 intent: "save_pattern",
472 id: form.id ?? "",
473 name: form.name,
474 pattern: form.pattern,
475 before: form.before,
476 after: form.after,
477 testStrings: form.testStrings,
478 publish: String(publish),
479 });
480 const tests = form.testStrings.split("\n").filter((line) => line.trim());
481 const saved = save.data?.saved;
482 return (
483 <div className="space-y-4">
484 <div className="grid gap-4 sm:grid-cols-2">
485 <label className="block">
486 <span className="mb-1.5 block text-xs font-medium text-muted">Name</span>
487 <input value={form.name} onChange={set("name")} maxLength={100} placeholder="Acme API key" className={INPUT} />
488 </label>
489 <label className="block sm:col-span-2">
490 <span className="mb-1.5 block text-xs font-medium text-muted">Secret format (regular expression)</span>
491 <input
492 value={form.pattern}
493 onChange={set("pattern")}
494 maxLength={1000}
495 spellCheck={false}
496 placeholder="acme_[a-z0-9]{32}"
497 className={`${INPUT} font-mono`}
498 />
499 </label>
500 <label className="block">
501 <span className="mb-1.5 block text-xs font-medium text-muted">Before the secret (optional)</span>
502 <input value={form.before} onChange={set("before")} spellCheck={false} placeholder="\A|[^0-9A-Za-z]" className={`${INPUT} font-mono`} />
503 </label>
504 <label className="block">
505 <span className="mb-1.5 block text-xs font-medium text-muted">After the secret (optional)</span>
506 <input value={form.after} onChange={set("after")} spellCheck={false} placeholder="\z|[^0-9A-Za-z]" className={`${INPUT} font-mono`} />
507 </label>
508 <label className="block sm:col-span-2">
509 <span className="mb-1.5 block text-xs font-medium text-muted">Test strings, one a line</span>
510 <textarea value={form.testStrings} onChange={set("testStrings")} rows={3} spellCheck={false} className={`${INPUT} font-mono`} />
511 </label>
512 </div>
513 <p className="text-xs text-muted">
514 Patterns use the Rust regex syntax, which runs in time linear in the text: no look-around or back-references. Before and
515 after default to a line edge or a character that is not a letter or digit.
516 </p>
517 {saved && tests.length > 0 && (
518 <ul className="space-y-1">
519 {tests.map((text, at) => (
520 <TestResult key={at} text={text} found={saved.tests[at] ?? null} />
521 ))}
522 </ul>
523 )}
524 <div className="flex flex-wrap items-center gap-2">
525 <Button
526 type="button"
527 disabled={save.state !== "idle" || !form.name.trim() || !form.pattern.trim()}
528 onClick={() => save.submit(fields(true), { method: "post", action })}>
529 {save.state !== "idle" ? "Saving…" : form.published ? "Save" : "Publish"}
530 </Button>
531 <button
532 type="button"
533 disabled={save.state !== "idle" || !form.name.trim() || !form.pattern.trim()}
534 onClick={() => save.submit(fields(false), { method: "post", action })}
535 className={SMALL_BUTTON}
536 >
537 {form.published ? "Unpublish, keep as draft" : "Save as draft"}
538 </button>
539 <button
540 type="button"
541 disabled={dry.state !== "idle" || !form.pattern.trim()}
542 onClick={() => dry.submit({ intent: "dry_run", pattern: form.pattern, before: form.before, after: form.after }, { method: "post", action })}
543 className={SMALL_BUTTON}
544 >
545 {dry.state !== "idle" ? "Running…" : "Dry run"}
546 </button>
547 {form.id && (
548 <Button
549 type="button"
550 disabled={save.state !== "idle"}
551 onClick={() => {
552 if (confirm(`Delete "${form.name}"? The alerts it found stay.`)) save.submit({ intent: "delete_pattern", id: form.id ?? "" }, { method: "post", action });
553 }}
554 variant="destructive"
555 size="inline"
556 className="ml-auto px-2.5 py-1 text-xs"
557 >
558 Delete
559 </Button>
560 )}
561 </div>
562 {save.data?.error && <p className="text-sm text-danger">{save.data.error}</p>}
563 {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>)}
564 {dry.data?.error && <p className="text-sm text-danger">{dry.data.error}</p>}
565 {dry.data?.dryRun && <DryRunResults dryRun={dry.data.dryRun} />}
566 </div>
567 );
568}
569
570function DryRunResults({ dryRun }: { dryRun: DryRun }) {
571 return (
572 <div className="space-y-3">
573 {dryRun.repos.map((repo) => (
574 <section key={repo.name} className={`${CARD} p-3`}>
575 <p className="text-sm">
576 <span className="font-mono font-medium">{repo.name}</span>
577 <span className="text-muted">
578 {" "}
579 · {repo.matches.length} {repo.matches.length === 1 ? "match" : "matches"} in {repo.filesScanned} files
580 {repo.truncated ? " (stopped early)" : ""}
581 </span>
582 </p>
583 {repo.matches.length > 0 && (
584 <ul className="mt-2 space-y-1">
585 {repo.matches.map((match, at) => (
586 <li key={at} className="font-mono text-xs break-all">
587 <span className="inline-flex items-center gap-1 text-fg-soft">
588 <FileCode2 size={12} />
589 {match.path}:{match.line}
590 </span>
591 <span className="text-faint"> {match.preview}</span>
592 </li>
593 ))}
594 </ul>
595 )}
596 </section>
597 ))}
598 </div>
599 );
600}
601
602/** Reads a pattern form's fields, as the pages' actions take them. */
603export function patternFields(form: FormData) {
604 const text = (key: string) => String(form.get(key) ?? "");
605 return {
606 id: text("id") || undefined,
607 name: text("name").trim(),
608 pattern: text("pattern"),
609 before: text("before").trim() || null,
610 after: text("after").trim() || null,
611 testStrings: text("testStrings")
612 .split("\n")
613 .map((line) => line.replace(/\r$/, ""))
614 .filter((line) => line.trim()),
615 publish: text("publish") === "true",
616 };
617}