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

204 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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} &lt;{details.email}&gt; · {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>
);
}