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

195 lines
7.0 KiB
TypeScript

/**
* @file One file's history (Show History), like GitLens' File History: every
* commit that touched it, newest first, followed across renames (`git log
* --follow`). A version opens that commit's diff of the file under the name
* it had there (the panel's own diff viewer); Blame opens the file as it was
* at that version with each line's commit (BlameView), and a rename says
* where it came from. Paged with Load more.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import type { FileVersion } from "../../../lib/gitHistory";
import { timeAgo } from "../../../lib/format";
import { BlameView } from "./BlameView";
const PAGE = 100;
export function FileHistory({
target,
path,
reread,
onOpenVersion,
onViewCommit,
onWholeBranch,
}: {
target: GitTarget;
path: string;
reread: number;
/** That version's diff of the file: its name THEN, and the commit. */
onOpenVersion: (path: string, sha: string) => void;
onViewCommit: (sha: string) => void;
onWholeBranch: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [versions, setVersions] = useState<FileVersion[] | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [blame, setBlame] = useState<{ path: string; rev: string } | null>(null);
const seq = useRef(0);
useEffect(() => {
const mine = ++seq.current;
setVersions(null);
setError(null);
setLoading(true);
void api.lanes.gitHistory
.fileHistory(target, path, { limit: PAGE })
.then((r) => {
if (seq.current !== mine) return;
setVersions(r.available ? r.commits : []);
setHasMore(r.available && r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
}, [path, reread]);
const more = useCallback(() => {
if (!versions || loading) return;
const mine = ++seq.current;
setLoading(true);
void api.lanes.gitHistory
.fileHistory(target, path, { limit: PAGE, skip: versions.length })
.then((r) => {
if (seq.current !== mine || !r.available) return;
setVersions((v) => [...(v ?? []), ...r.commits]);
setHasMore(r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
}, [versions, loading, target, path]);
return (
<div data-testid="file-history" className="flex min-h-0 flex-col gap-1">
<div
data-testid="lane-changes-history-file"
className="flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
>
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
{t("lanes:changes.historyOfFile", { path })}
</span>
<button
type="button"
onClick={() => setBlame({ path, rev: "work" })}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
{t("blame")}
</button>
<button
type="button"
onClick={onWholeBranch}
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("lanes:changes.historyWholeBranch")}
</button>
</div>
{error && (
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
{versions === null && !error && (
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
)}
{versions && versions.length === 0 && (
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.noCommits")}</p>
)}
<ul>
{versions?.map((v) => (
<li key={v.sha} className="group flex items-center gap-1 rounded px-1 hover:bg-surface-2">
<button
type="button"
onClick={() => onOpenVersion(v.path, v.sha)}
className="flex min-w-0 flex-1 flex-col py-0.5 text-left"
>
<span className="flex min-w-0 items-baseline gap-1.5">
<span
className="w-3 shrink-0 font-mono text-[10px] text-fg-muted"
title={t(`status.${v.status}`, { defaultValue: v.status })}
>
{v.status}
</span>
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{v.short}</span>
<span
className="min-w-0 flex-1 truncate text-[11px] text-fg-primary"
title={v.subject}
>
{v.subject}
</span>
</span>
<span className="flex min-w-0 items-baseline gap-1.5 pl-[1.2rem] text-[10px] text-fg-muted">
<span className="min-w-0 truncate">{v.author}</span>
<span className="shrink-0" title={new Date(v.date).toLocaleString()}>
{timeAgo(v.date)}
</span>
{v.from && (
<span className="min-w-0 [overflow-wrap:anywhere]">
{t("renamedFrom", { from: v.from })}
</span>
)}
</span>
</button>
{v.status !== "D" && (
<button
type="button"
onClick={() => setBlame({ path: v.path, rev: v.sha })}
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
{t("blame")}
</button>
)}
<button
type="button"
onClick={() => onViewCommit(v.sha)}
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
{t("commit")}
</button>
</li>
))}
</ul>
{hasMore && (
<button
type="button"
data-testid="file-history-more"
disabled={loading}
onClick={more}
className="w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
</button>
)}
{blame && (
<BlameView
target={target}
path={blame.path}
rev={blame.rev}
onClose={() => setBlame(null)}
onOpenCommit={(sha) => {
setBlame(null);
onViewCommit(sha);
}}
/>
)}
</div>
);
}