195 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|