204 lines
7.9 KiB
TypeScript
204 lines
7.9 KiB
TypeScript
/**
|
||
* @file The History tab's commit details pane (GitKraken's / JetBrains'
|
||
* right-hand commit panel): the full message, author and committer with
|
||
* dates, parents (each opens that commit), the commit's refs and the branches
|
||
* and tags that contain it, its changed files with +/- counts (a file opens
|
||
* that commit's diff of it in the panel's own diff viewer), and Copy SHA,
|
||
* Copy patch, Browse files, Compare with current and View all changes.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { X } from "lucide-react";
|
||
import { api, type GitTarget } from "../../../lib/api";
|
||
import type { CommitDetails as Details } from "../../../lib/gitHistory";
|
||
import type { LaneChangeFile } from "../../../lib/types";
|
||
import { copyText } from "../../../lib/clipboard";
|
||
import { RefChip, sortBadges } from "./CommitGraph";
|
||
|
||
export function CommitDetails({
|
||
target,
|
||
sha,
|
||
onClose,
|
||
onOpenCommit,
|
||
onViewChanges,
|
||
onOpenFile,
|
||
onBrowse,
|
||
onCompare,
|
||
onCopyPatch,
|
||
}: {
|
||
target: GitTarget;
|
||
sha: string;
|
||
onClose: () => void;
|
||
/** A parent clicked: focus that commit. */
|
||
onOpenCommit: (sha: string) => void;
|
||
onViewChanges: () => void;
|
||
onOpenFile: (path: string) => void;
|
||
onBrowse: () => void;
|
||
onCompare: () => void;
|
||
onCopyPatch: () => void;
|
||
}) {
|
||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||
const [details, setDetails] = useState<Details | null>(null);
|
||
const [files, setFiles] = useState<LaneChangeFile[] | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let live = true;
|
||
void api.lanes.gitHistory
|
||
.commit(target, sha)
|
||
.then((r) => live && (r.available ? setDetails(r) : setError(t("noDetails"))))
|
||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||
void api.lanes
|
||
.changes(target, { range: "commit", sha })
|
||
.then((r) => live && setFiles(r.available ? r.files : []))
|
||
.catch(() => live && setFiles([]));
|
||
return () => {
|
||
live = false;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on sha by the parent
|
||
}, [sha]);
|
||
|
||
const button =
|
||
"shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary";
|
||
const added = files?.reduce((n, f) => n + (f.added ?? 0), 0) ?? 0;
|
||
const deleted = files?.reduce((n, f) => n + (f.deleted ?? 0), 0) ?? 0;
|
||
|
||
return (
|
||
<section
|
||
data-testid="commit-details"
|
||
aria-label={t("detailsTitle")}
|
||
className="max-h-[45%] min-h-[6rem] shrink-0 overflow-y-auto rounded border border-border-light bg-surface-0 p-2 text-[11px]"
|
||
>
|
||
<div className="mb-1 flex items-start gap-1.5">
|
||
<span className="font-mono text-fg-muted">{sha.slice(0, 10)}</span>
|
||
<span className="min-w-0 flex-1" />
|
||
<button
|
||
type="button"
|
||
aria-label={t("close")}
|
||
onClick={onClose}
|
||
className="shrink-0 text-fg-muted hover:text-fg-primary"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
{error && <p className="text-status-danger [overflow-wrap:anywhere]">{error}</p>}
|
||
{!details && !error && <p className="text-fg-muted">{t("lanes:changes.loading")}</p>}
|
||
{details && (
|
||
<>
|
||
<p
|
||
data-testid="commit-details-message"
|
||
className="mb-1.5 whitespace-pre-wrap text-fg-primary [overflow-wrap:anywhere]"
|
||
>
|
||
{details.message}
|
||
</p>
|
||
<dl className="mb-1.5 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-fg-secondary">
|
||
<dt className="text-fg-muted">{t("author")}</dt>
|
||
<dd className="min-w-0 [overflow-wrap:anywhere]">
|
||
{details.author} <{details.email}> · {new Date(details.date).toLocaleString()}
|
||
</dd>
|
||
{(details.committer !== details.author || details.committedAt !== details.date) && (
|
||
<>
|
||
<dt className="text-fg-muted">{t("committer")}</dt>
|
||
<dd className="min-w-0 [overflow-wrap:anywhere]">
|
||
{details.committer} · {new Date(details.committedAt).toLocaleString()}
|
||
</dd>
|
||
</>
|
||
)}
|
||
<dt className="text-fg-muted">{t("parents", { count: details.parents.length })}</dt>
|
||
<dd className="flex min-w-0 flex-wrap gap-1">
|
||
{details.parents.length === 0 && <span>{t("rootCommit")}</span>}
|
||
{details.parents.map((p) => (
|
||
<button
|
||
key={p}
|
||
type="button"
|
||
onClick={() => onOpenCommit(p)}
|
||
className="font-mono text-accent hover:underline"
|
||
>
|
||
{p.slice(0, 7)}
|
||
</button>
|
||
))}
|
||
</dd>
|
||
{details.badges.length > 0 && (
|
||
<>
|
||
<dt className="text-fg-muted">{t("refs")}</dt>
|
||
<dd className="flex min-w-0 flex-wrap gap-1">
|
||
{sortBadges(details.badges).map((b) => (
|
||
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
|
||
))}
|
||
</dd>
|
||
</>
|
||
)}
|
||
{(details.branches.length > 0 || details.tags.length > 0) && (
|
||
<>
|
||
<dt className="text-fg-muted">{t("containedIn")}</dt>
|
||
<dd className="min-w-0 [overflow-wrap:anywhere]">
|
||
{[...details.branches, ...details.tags.map((x) => `tag ${x}`)].join(", ")}
|
||
{details.containsTruncated ? " …" : ""}
|
||
</dd>
|
||
</>
|
||
)}
|
||
</dl>
|
||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||
<button type="button" className={button} onClick={onViewChanges}>
|
||
{t("viewAllChanges")}
|
||
</button>
|
||
<button type="button" className={button} onClick={() => void copyText(details.sha)}>
|
||
{t("lanes:changes.copySha")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={button}
|
||
disabled={details.parents.length > 1}
|
||
onClick={onCopyPatch}
|
||
>
|
||
{t("copyPatch")}
|
||
</button>
|
||
<button type="button" className={button} onClick={onBrowse}>
|
||
{t("browseFiles")}
|
||
</button>
|
||
<button type="button" className={button} onClick={onCompare}>
|
||
{t("compareWithCurrent")}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
{files && (
|
||
<>
|
||
<p className="mb-0.5 text-fg-muted">
|
||
{t("filesChanged", { count: files.length })}{" "}
|
||
<span className="text-status-success">+{added}</span>{" "}
|
||
<span className="text-status-danger">−{deleted}</span>
|
||
</p>
|
||
<ul data-testid="commit-details-files">
|
||
{files.map((f) => (
|
||
<li key={f.path}>
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpenFile(f.path)}
|
||
title={f.path}
|
||
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left hover:bg-surface-2"
|
||
>
|
||
<span className="w-3 shrink-0 font-mono text-fg-muted">{f.status.trim()}</span>
|
||
<span className="min-w-0 flex-1 text-fg-primary [overflow-wrap:anywhere]">
|
||
{f.path}
|
||
</span>
|
||
{f.binary ? (
|
||
<span className="shrink-0 text-fg-muted">{t("binary")}</span>
|
||
) : (
|
||
<span className="shrink-0 font-mono">
|
||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||
</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|