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.

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