{
  "name": "agent-diffstat",
  "dependencies": [],
  "registryDependencies": [
    "file-icon"
  ],
  "files": [
    {
      "path": "agent-diffstat.tsx",
      "content": "\"use client\";\r\n\r\nimport { css, themeVars as theme } from \"@yugnex/core\";\r\nimport { useMemo, useState, type ReactNode } from \"react\";\r\nimport { FileIcon, type FileStatus } from \"./file-icon\";\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Model\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface DiffStatEntry {\r\n  path: string;\r\n  additions: number;\r\n  deletions: number;\r\n  status?: FileStatus;\r\n  /** Marks a rename; the old path is shown struck through. */\r\n  previousPath?: string;\r\n  /** Suppresses the sparkbar and shows \"binary\" instead. */\r\n  binary?: boolean;\r\n}\r\n\r\nexport interface DiffStatTotals {\r\n  files: number;\r\n  additions: number;\r\n  deletions: number;\r\n}\r\n\r\nexport type DiffStatSort = \"churn\" | \"path\" | \"additions\" | \"deletions\";\r\n\r\n/** Sums a changeset. Exported so a header can show totals without the list. */\r\nexport function totalsOf(entries: DiffStatEntry[]): DiffStatTotals {\r\n  let additions = 0;\r\n  let deletions = 0;\r\n  for (const entry of entries) {\r\n    additions += entry.additions;\r\n    deletions += entry.deletions;\r\n  }\r\n  return { files: entries.length, additions, deletions };\r\n}\r\n\r\n/** Sorts a copy of the entries. `churn` is additions+deletions, descending. */\r\nexport function sortEntries(entries: DiffStatEntry[], sort: DiffStatSort): DiffStatEntry[] {\r\n  const copy = [...entries];\r\n  switch (sort) {\r\n    case \"path\":\r\n      return copy.sort((a, b) => a.path.localeCompare(b.path));\r\n    case \"additions\":\r\n      return copy.sort((a, b) => b.additions - a.additions || a.path.localeCompare(b.path));\r\n    case \"deletions\":\r\n      return copy.sort((a, b) => b.deletions - a.deletions || a.path.localeCompare(b.path));\r\n    case \"churn\":\r\n    default:\r\n      return copy.sort(\r\n        (a, b) => b.additions + b.deletions - (a.additions + a.deletions) || a.path.localeCompare(b.path),\r\n      );\r\n  }\r\n}\r\n\r\n/**\r\n * Allocates a file's sparkbar into whole add/delete segments out of `width`.\r\n *\r\n * Scaled against the *largest* file's churn, not each file's own, so bar\r\n * length is comparable down the list — the point of the column is spotting\r\n * which file carries the change, which a per-row normalisation would destroy\r\n * by making every row full width.\r\n *\r\n * A file with any additions always gets at least one segment, so a one-line\r\n * change never renders as an empty bar.\r\n */\r\nexport function allocateBar(\r\n  additions: number,\r\n  deletions: number,\r\n  maxChurn: number,\r\n  width: number,\r\n): { add: number; del: number } {\r\n  const churn = additions + deletions;\r\n  if (churn === 0 || maxChurn === 0 || width === 0) return { add: 0, del: 0 };\r\n\r\n  const total = Math.max(1, Math.round((churn / maxChurn) * width));\r\n\r\n  let add = Math.round((additions / churn) * total);\r\n  // Never round a non-zero side away to nothing, and never claim a segment\r\n  // for a side that contributed no lines.\r\n  if (additions > 0 && add === 0) add = 1;\r\n  if (additions === 0) add = 0;\r\n  if (add > total) add = total;\r\n\r\n  let del = total - add;\r\n  if (deletions > 0 && del === 0 && total > 1) {\r\n    del = 1;\r\n    add = total - 1;\r\n  }\r\n  if (deletions === 0) del = 0;\r\n\r\n  return { add, del };\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Styles\r\n * ------------------------------------------------------------------ */\r\n\r\nconst rootClass = css({\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.sm,\r\n  color: theme.color.foreground,\r\n  border: `1px solid ${theme.color.border}`,\r\n  borderRadius: theme.radius.md,\r\n  backgroundColor: theme.color.card,\r\n  overflow: \"hidden\",\r\n});\r\n\r\nconst summaryClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[3],\r\n  padding: `${theme.space[2]} ${theme.space[3]}`,\r\n  borderBottom: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.muted,\r\n  flexWrap: \"wrap\",\r\n});\r\n\r\nconst summaryTextClass = css({\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.mutedForeground,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n});\r\n\r\nconst countsClass = css({\r\n  display: \"inline-flex\",\r\n  gap: theme.space[2],\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: theme.fontSize.xs,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n  marginLeft: \"auto\",\r\n});\r\n\r\nconst addTextClass = css({ color: theme.color.success });\r\nconst delTextClass = css({ color: theme.color.destructive });\r\n\r\nconst sortGroupClass = css({\r\n  display: \"inline-flex\",\r\n  gap: \"2px\",\r\n  padding: \"2px\",\r\n  borderRadius: theme.radius.sm,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.background,\r\n});\r\n\r\nconst sortButtonClass = css({\r\n  padding: `1px ${theme.space[1.5]}`,\r\n  borderRadius: theme.radius.sm,\r\n  border: \"none\",\r\n  background: \"transparent\",\r\n  color: theme.color.mutedForeground,\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: \"0.6875rem\",\r\n  cursor: \"pointer\",\r\n  transitionProperty: \"background-color, color\",\r\n  transitionDuration: theme.duration.fast,\r\n  \"&:hover\": { color: theme.color.foreground },\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"1px\" },\r\n  '&[aria-pressed=\"true\"]': { backgroundColor: theme.color.primary, color: theme.color.primaryForeground },\r\n});\r\n\r\nconst listClass = css({ margin: 0, padding: 0, listStyle: \"none\", maxHeight: \"24rem\", overflowY: \"auto\" });\r\n\r\nconst rowClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[2],\r\n  width: \"100%\",\r\n  padding: `${theme.space[1.5]} ${theme.space[3]}`,\r\n  border: \"none\",\r\n  background: \"transparent\",\r\n  color: \"inherit\",\r\n  font: \"inherit\",\r\n  textAlign: \"left\",\r\n  cursor: \"pointer\",\r\n  transitionProperty: \"background-color\",\r\n  transitionDuration: theme.duration.fast,\r\n  \"&:hover\": { backgroundColor: theme.color.muted },\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"-2px\" },\r\n  '&[aria-current=\"true\"]': { backgroundColor: theme.color.accent, color: theme.color.accentForeground },\r\n});\r\n\r\nconst staticRowClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[2],\r\n  padding: `${theme.space[1.5]} ${theme.space[3]}`,\r\n});\r\n\r\nconst pathClass = css({\r\n  flex: 1,\r\n  minWidth: 0,\r\n  overflow: \"hidden\",\r\n  textOverflow: \"ellipsis\",\r\n  whiteSpace: \"nowrap\",\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: theme.fontSize.xs,\r\n  // Truncating a path from the left keeps the filename visible, which is the\r\n  // part that identifies it; the leading directories are the disposable half.\r\n  direction: \"rtl\",\r\n  textAlign: \"left\",\r\n});\r\n\r\nconst renameClass = css({\r\n  color: theme.color.mutedForeground,\r\n  textDecoration: \"line-through\",\r\n  marginRight: theme.space[1],\r\n});\r\n\r\nconst numbersClass = css({\r\n  flexShrink: 0,\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: \"0.6875rem\",\r\n  fontVariantNumeric: \"tabular-nums\",\r\n  color: theme.color.mutedForeground,\r\n  minWidth: \"4.5rem\",\r\n  textAlign: \"right\",\r\n});\r\n\r\nconst barClass = css({\r\n  display: \"inline-flex\",\r\n  gap: \"1px\",\r\n  flexShrink: 0,\r\n  alignItems: \"center\",\r\n});\r\n\r\nconst segmentClass = css({ width: \"5px\", height: \"9px\", borderRadius: \"1px\" });\r\n\r\nconst binaryClass = css({\r\n  flexShrink: 0,\r\n  fontSize: \"0.6875rem\",\r\n  color: theme.color.mutedForeground,\r\n  fontStyle: \"italic\",\r\n});\r\n\r\nconst emptyClass = css({\r\n  padding: theme.space[4],\r\n  color: theme.color.mutedForeground,\r\n  fontSize: theme.fontSize.sm,\r\n  textAlign: \"center\",\r\n});\r\n\r\nconst STATUS_COLOR: Record<Exclude<FileStatus, \"unchanged\">, string> = {\r\n  new: theme.color.success,\r\n  modified: theme.color.warning,\r\n  deleted: theme.color.destructive,\r\n};\r\n\r\nconst statusDotClass = css({ flexShrink: 0, width: \"6px\", height: \"6px\", borderRadius: \"9999px\" });\r\n\r\nconst SORTS: Array<{ id: DiffStatSort; label: string }> = [\r\n  { id: \"churn\", label: \"Churn\" },\r\n  { id: \"path\", label: \"Path\" },\r\n  { id: \"additions\", label: \"+\" },\r\n  { id: \"deletions\", label: \"−\" },\r\n];\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Component\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface AgentDiffstatProps {\r\n  entries: DiffStatEntry[];\r\n  /** Number of segments in the widest sparkbar. */\r\n  barWidth?: number;\r\n  defaultSort?: DiffStatSort;\r\n  /** Hides the sort control when false. */\r\n  sortable?: boolean;\r\n  /** Path of the row to mark as current. */\r\n  selected?: string;\r\n  onSelect?: (path: string) => void;\r\n  /** Replaces the built-in summary line. */\r\n  summary?: ReactNode;\r\n  label?: string;\r\n  className?: string;\r\n}\r\n\r\n/**\r\n * The changeset summary: totals plus a per-file add/delete sparkbar.\r\n *\r\n * Answers \"what am I about to accept\" in one glance, and pairs with\r\n * review-gate — a reviewer reads the shape of the change here, then decides\r\n * there.\r\n */\r\nexport function AgentDiffstat({\r\n  entries,\r\n  barWidth = 12,\r\n  defaultSort = \"churn\",\r\n  sortable = true,\r\n  selected,\r\n  onSelect,\r\n  summary,\r\n  label = \"Changeset summary\",\r\n  className,\r\n}: AgentDiffstatProps) {\r\n  const [sort, setSort] = useState<DiffStatSort>(defaultSort);\r\n\r\n  const totals = useMemo(() => totalsOf(entries), [entries]);\r\n  const sorted = useMemo(() => sortEntries(entries, sort), [entries, sort]);\r\n  const maxChurn = useMemo(\r\n    () => entries.reduce((max, e) => Math.max(max, e.additions + e.deletions), 0),\r\n    [entries],\r\n  );\r\n\r\n  if (entries.length === 0) {\r\n    return (\r\n      <div className={className ? `${rootClass} ${className}` : rootClass} aria-label={label}>\r\n        <div className={emptyClass}>No files changed.</div>\r\n      </div>\r\n    );\r\n  }\r\n\r\n  return (\r\n    <div className={className ? `${rootClass} ${className}` : rootClass} aria-label={label}>\r\n      <div className={summaryClass}>\r\n        {summary ?? (\r\n          <span className={summaryTextClass}>\r\n            {totals.files} {totals.files === 1 ? \"file\" : \"files\"} changed\r\n          </span>\r\n        )}\r\n\r\n        {sortable ? (\r\n          <div className={sortGroupClass} role=\"group\" aria-label=\"Sort files\">\r\n            {SORTS.map((option) => (\r\n              <button\r\n                key={option.id}\r\n                type=\"button\"\r\n                className={sortButtonClass}\r\n                aria-pressed={sort === option.id}\r\n                aria-label={`Sort by ${option.id}`}\r\n                onClick={() => setSort(option.id)}\r\n              >\r\n                {option.label}\r\n              </button>\r\n            ))}\r\n          </div>\r\n        ) : null}\r\n\r\n        <span className={countsClass}>\r\n          <span className={addTextClass}>+{totals.additions}</span>\r\n          <span className={delTextClass}>−{totals.deletions}</span>\r\n        </span>\r\n      </div>\r\n\r\n      <ul className={listClass}>\r\n        {sorted.map((entry) => {\r\n          const { add, del } = allocateBar(entry.additions, entry.deletions, maxChurn, barWidth);\r\n          const status = entry.status && entry.status !== \"unchanged\" ? entry.status : undefined;\r\n          const isSelected = entry.path === selected;\r\n\r\n          const inner = (\r\n            <>\r\n              <FileIcon filename={entry.path} size={14} />\r\n\r\n              {status ? (\r\n                <span\r\n                  className={statusDotClass}\r\n                  style={{ backgroundColor: STATUS_COLOR[status] }}\r\n                  aria-hidden=\"true\"\r\n                />\r\n              ) : null}\r\n\r\n              <span className={pathClass} title={entry.path}>\r\n                {/* Bidi isolate: with direction:rtl on the container, a path\r\n                    beginning with punctuation would otherwise be reordered. */}\r\n                {\"⁦\"}\r\n                {entry.previousPath ? <span className={renameClass}>{entry.previousPath} →</span> : null}\r\n                {entry.path}\r\n                {\"⁩\"}\r\n              </span>\r\n\r\n              {entry.binary ? (\r\n                <span className={binaryClass}>binary</span>\r\n              ) : (\r\n                <>\r\n                  <span className={numbersClass}>\r\n                    +{entry.additions} −{entry.deletions}\r\n                  </span>\r\n                  <span\r\n                    className={barClass}\r\n                    aria-label={`${entry.additions} additions, ${entry.deletions} deletions`}\r\n                  >\r\n                    {Array.from({ length: add }, (_, i) => (\r\n                      <span\r\n                        key={`a${i}`}\r\n                        className={segmentClass}\r\n                        style={{ backgroundColor: theme.color.success }}\r\n                        aria-hidden=\"true\"\r\n                      />\r\n                    ))}\r\n                    {Array.from({ length: del }, (_, i) => (\r\n                      <span\r\n                        key={`d${i}`}\r\n                        className={segmentClass}\r\n                        style={{ backgroundColor: theme.color.destructive }}\r\n                        aria-hidden=\"true\"\r\n                      />\r\n                    ))}\r\n                  </span>\r\n                </>\r\n              )}\r\n            </>\r\n          );\r\n\r\n          return (\r\n            <li key={entry.path}>\r\n              {onSelect ? (\r\n                <button\r\n                  type=\"button\"\r\n                  className={rowClass}\r\n                  aria-current={isSelected}\r\n                  onClick={() => onSelect(entry.path)}\r\n                >\r\n                  {inner}\r\n                </button>\r\n              ) : (\r\n                <div className={staticRowClass}>{inner}</div>\r\n              )}\r\n            </li>\r\n          );\r\n        })}\r\n      </ul>\r\n    </div>\r\n  );\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}