183 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
});
|