Files
AgentHub/client/src/components/lanes/git/CommitGraph.tsx
T

183 lines
5.7 KiB
TypeScript

/**
* @file The History tab's commit rows, drawn like VS Code's Source Control
* Graph / GitLens: the graph cell (lanes and merge edges from
* `lib/commitGraph`), the ref badges (current branch, local and remote
* branches, tags, a detached HEAD), the subject, author and relative date.
* Colours are theme tokens (CSS variables), so a lane reads in every theme.
* Long subjects and names truncate with the whole text in a tooltip.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { Cloud, GitBranch, Tag, Crosshair } from "lucide-react";
import type { GraphRow } from "../../../lib/commitGraph";
import type { RefBadge } from "../../../lib/gitHistory";
import { timeAgo } from "../../../lib/format";
/** One fixed-height row per commit so the lanes join up row to row. */
export const GRAPH_ROW = 30;
const GRAPH_COL = 12;
/** Columns drawn at most; a wider graph is clipped, not the row. */
const MAX_COLS = 12;
/** Theme tokens, cycled per lane. */
const GRAPH_COLORS = [
"rgb(var(--accent))",
"rgb(var(--status-success))",
"rgb(var(--status-warning))",
"rgb(var(--syn-property))",
"rgb(var(--status-danger))",
"rgb(var(--syn-function))",
"rgb(var(--kept))",
"rgb(var(--diff-mod))",
];
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
export function GraphCell({
row,
width,
merge,
head,
}: {
row: GraphRow;
width: number;
/** A merge commit: a hollow node. */
merge?: boolean;
/** HEAD: a ringed node. */
head?: boolean;
}) {
const cols = Math.min(width, MAX_COLS);
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
const y = (f: number) => f * GRAPH_ROW;
return (
<svg
aria-hidden
width={cols * GRAPH_COL}
height={GRAPH_ROW}
className="shrink-0 overflow-hidden"
data-testid="lane-changes-graph"
>
{row.segs.map((g, i) => {
const ym = (y(g.y1) + y(g.y2)) / 2;
return (
<path
key={i}
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
fill="none"
stroke={hue(g.color)}
strokeWidth={1.5}
/>
);
})}
{head && (
<circle
cx={x(row.col)}
cy={y(0.5)}
r={6}
fill="none"
stroke={hue(row.color)}
strokeWidth={1.5}
/>
)}
<circle
cx={x(row.col)}
cy={y(0.5)}
r={3.5}
fill={merge ? "rgb(var(--surface-1))" : hue(row.color)}
stroke={hue(row.color)}
strokeWidth={merge ? 1.5 : 0}
/>
</svg>
);
}
/** One ref badge: the kind's icon, the name truncated, the full name on hover. */
export function RefChip({ badge }: { badge: RefBadge }) {
const { t } = useTranslation(["gitHistory"]);
const Icon =
badge.kind === "tag"
? Tag
: badge.kind === "remote"
? Cloud
: badge.kind === "head"
? Crosshair
: GitBranch;
const tone = badge.current
? "border-accent/60 bg-accent/15 text-accent font-semibold"
: badge.kind === "tag"
? "border-status-warning/50 text-status-warning"
: badge.kind === "remote"
? "border-border text-fg-muted"
: badge.kind === "head"
? "border-status-danger/50 text-status-danger"
: "border-status-success/50 text-status-success";
const label = t(`badge.${badge.kind}`, { name: badge.name });
return (
<span
title={badge.current ? t("badge.current", { name: badge.name }) : label}
data-testid="ref-badge"
data-kind={badge.kind}
className={`inline-flex max-w-[10rem] shrink items-center gap-0.5 rounded border px-1 text-[10px] leading-4 ${tone}`}
>
<Icon className="h-2.5 w-2.5 shrink-0" aria-hidden />
<span className="truncate">{badge.name}</span>
</span>
);
}
/** Badges in the order the reference tools show them: HEAD's branch first. */
export function sortBadges(badges: RefBadge[]): RefBadge[] {
const rank = (b: RefBadge) =>
b.current ? 0 : b.kind === "head" ? 1 : b.kind === "local" ? 2 : b.kind === "remote" ? 3 : 4;
return [...badges].sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
}
export interface GraphCommit {
sha: string;
short: string;
subject: string;
author: string;
email?: string;
date: string;
parents: string[];
badges?: RefBadge[];
}
/** The text half of a history row (the graph cell is drawn beside it). */
export const CommitText = memo(function CommitText({ c }: { c: GraphCommit }) {
const badges = sortBadges(c.badges ?? []);
const when = new Date(c.date);
return (
<span className="flex min-w-0 flex-1 flex-col justify-center">
<span className="flex min-w-0 items-center gap-1">
{badges.slice(0, 4).map((b) => (
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
))}
{badges.length > 4 && (
<span
className="shrink-0 text-[10px] text-fg-muted"
title={badges
.slice(4)
.map((b) => b.name)
.join(", ")}
>
+{badges.length - 4}
</span>
)}
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary" title={c.subject}>
{c.subject}
</span>
</span>
<span className="flex min-w-0 items-baseline gap-1.5 text-[10px] text-fg-muted">
<span className="shrink-0 font-mono">{c.short}</span>
<span className="min-w-0 truncate" title={c.email ? `${c.author} <${c.email}>` : c.author}>
{c.author}
</span>
<span className="shrink-0" title={when.toLocaleString()}>
{timeAgo(c.date)}
</span>
</span>
</span>
);
});