import type { ReactNode } from "react" import { ArrowRight, BookmarkCheck, CircleCheck, Lock, Pencil, Trash2, Undo2 } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { cn } from "@/lib/utils" import type { ChangesetResponse, RecordView } from "@/api/types" type Tone = "update" | "delete" | "custom" | "readonly" const TONE_META: Record< Tone, { label: string; empty: string; icon: typeof Pencil; dot: string; ring: string } > = { update: { label: "Updates", empty: "Нет изменений — все значения совпадают.", icon: Pencil, dot: "var(--diff-update)", ring: "ring-[color-mix(in_oklch,var(--diff-update),transparent_78%)]", }, delete: { label: "Prunes", empty: "Нечего удалять.", icon: Trash2, dot: "var(--diff-delete)", ring: "ring-[color-mix(in_oklch,var(--diff-delete),transparent_78%)]", }, custom: { label: "Customs", empty: "Нет записей, помеченных как осознанные.", icon: BookmarkCheck, dot: "var(--diff-custom)", ring: "ring-[color-mix(in_oklch,var(--diff-custom),transparent_80%)]", }, readonly: { label: "Read-only", empty: "Нет read-only записей.", icon: Lock, dot: "var(--diff-readonly)", ring: "ring-[color-mix(in_oklch,var(--diff-readonly),transparent_82%)]", }, } function Values({ values }: { values?: string[] }) { if (!values || values.length === 0) { return } return <>{values.join(", ")} } function RecordRow({ record, tone, checked, onToggle, action, }: { record: RecordView tone: Tone checked?: boolean onToggle?: (key: string) => void action?: ReactNode }) { const meta = TONE_META[tone] const showArrow = tone !== "delete" // Read-only records aren't selectable — onToggle is only passed for // update/delete sections. Presence of onToggle is the selectability flag, // not the tone, so this stays correct if a tone's selectability ever changes. const selectable = onToggle !== undefined return (
{/* Top line: (optional) checkbox, type badge, name, read-only flag — always single-line, never affected by how long the record values are. */}
{selectable && ( onToggle!(record.key)} aria-label={`${tone === "delete" ? "Удалить" : "Применить"} ${record.type} ${record.name}`} className="shrink-0" /> )} {record.type} {record.name} {record.readOnly && ( read-only )} {action}
{/* Values line: plain block-level text (not flex) so a long unbreakable value like a DKIM key wraps within the row's own width instead of stretching it — a flex item's content can otherwise refuse to shrink below its intrinsic width. Indented to align under the name (badge width + gap, plus checkbox width + gap when this row is selectable). */}
) } function Section({ tone, records, selected, onToggle, onToggleAll, renderAction, }: { tone: Tone records: RecordView[] selected?: Set onToggle?: (key: string) => void onToggleAll?: (checked: boolean) => void renderAction?: (record: RecordView) => ReactNode }) { const meta = TONE_META[tone] const Icon = meta.icon // Read-only (NS/SOA) and custom records are never selectable — only // update/delete sections receive selection props from DiffView. const selectable = tone !== "readonly" && tone !== "custom" && !!selected && !!onToggle && !!onToggleAll const allSelected = selectable && records.length > 0 && records.every((r) => selected!.has(r.key)) const someSelected = selectable && records.some((r) => selected!.has(r.key)) const indeterminate = someSelected && !allSelected return (
{selectable && records.length > 0 && ( onToggleAll!(v === true)} aria-label={`Выбрать все — ${meta.label}`} /> )}

{meta.label}

{records.length}
{records.length === 0 ? (

{meta.empty}

) : (
{records.map((record) => ( ))}
)}
) } export function DiffView({ changeset, selectedUpdates, selectedPrunes, onToggleUpdate, onTogglePrune, onToggleAllUpdates, onToggleAllPrunes, onMarkCustom, onUnmarkCustom, footerExtra, }: { changeset: ChangesetResponse selectedUpdates: Set selectedPrunes: Set onToggleUpdate: (key: string) => void onTogglePrune: (key: string) => void onToggleAllUpdates: (checked: boolean) => void onToggleAllPrunes: (checked: boolean) => void onMarkCustom?: (key: string) => void onUnmarkCustom?: (key: string) => void footerExtra?: ReactNode }) { // Defensive: a field may arrive as null (e.g. a nil slice from an older // backend) — normalise to [] so Section never calls .length/.map on null. return (
( )) } />
( )) } />
{changeset.inSyncCount} record{changeset.inSyncCount === 1 ? "" : "s"} in sync
{footerExtra}
) }