246 lines
9.2 KiB
TypeScript
246 lines
9.2 KiB
TypeScript
/**
|
||
* @file Compare any two refs (GitLens' Search & Compare, GitHub's compare
|
||
* view): branches, tags or commits A and B; the commits only in A and only in
|
||
* B, and the files that differ — "Changes since they diverged" (A...B, what B
|
||
* adds; GitHub's default) or "Difference between the two" (A B, the trees as
|
||
* they stand). A file opens its diff in the panel's diff viewer; a commit
|
||
* opens its whole change. Swap flips the two sides.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { ArrowLeftRight } from "lucide-react";
|
||
import { api, type GitTarget } from "../../../lib/api";
|
||
import type { CompareResult, CompareFile } from "../../../lib/gitHistory";
|
||
import { LaneDiffView } from "../LaneDiffView";
|
||
import { GitModal } from "./GitModal";
|
||
|
||
type Tab = "files" | "onlyA" | "onlyB";
|
||
|
||
export function CompareRefs({
|
||
target,
|
||
initialA,
|
||
initialB,
|
||
refs,
|
||
onClose,
|
||
onOpenCommit,
|
||
}: {
|
||
target: GitTarget;
|
||
initialA: string;
|
||
initialB: string;
|
||
/** Branch names offered in the inputs' suggestion lists. */
|
||
refs: string[];
|
||
onClose: () => void;
|
||
onOpenCommit: (sha: string) => void;
|
||
}) {
|
||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||
const [a, setA] = useState(initialA);
|
||
const [b, setB] = useState(initialB);
|
||
const [asked, setAsked] = useState({ a: initialA, b: initialB });
|
||
const [mode, setMode] = useState<"three" | "two">("three");
|
||
const [result, setResult] = useState<CompareResult | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [tab, setTab] = useState<Tab>("files");
|
||
const [file, setFile] = useState<CompareFile["file"] | null>(null);
|
||
const [fileLoading, setFileLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let live = true;
|
||
setResult(null);
|
||
setError(null);
|
||
setFile(null);
|
||
void api.lanes.gitHistory
|
||
.compare(target, asked.a, asked.b, mode)
|
||
.then((r) => live && (r.available ? setResult(r) : setError(t("compareUnavailable"))))
|
||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||
return () => {
|
||
live = false;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable
|
||
}, [asked, mode]);
|
||
|
||
const openFile = (path: string, from?: string) => {
|
||
if (!result) return;
|
||
setFileLoading(true);
|
||
void api.lanes.gitHistory
|
||
.compareFile(target, result.a, result.b, mode, path, from)
|
||
.then((r) => r.available && setFile(r.file))
|
||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||
.finally(() => setFileLoading(false));
|
||
};
|
||
|
||
const input =
|
||
"min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5 font-mono text-[11px] text-fg-primary";
|
||
const tabBtn = (k: Tab, label: string) => (
|
||
<button
|
||
key={k}
|
||
type="button"
|
||
aria-pressed={tab === k}
|
||
onClick={() => {
|
||
setTab(k);
|
||
setFile(null);
|
||
}}
|
||
className={`rounded px-2 py-0.5 text-[11px] ${
|
||
tab === k ? "bg-accent-solid text-white" : "text-fg-secondary hover:bg-surface-3"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
const commits = tab === "onlyA" ? result?.onlyA : result?.onlyB;
|
||
|
||
return (
|
||
<GitModal tall testId="compare-refs" onClose={onClose} title={t("compareTitle")}>
|
||
<form
|
||
className="mb-2 flex flex-wrap items-center gap-1.5"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
if (a.trim() && b.trim()) setAsked({ a: a.trim(), b: b.trim() });
|
||
}}
|
||
>
|
||
<datalist id="compare-refs-list">
|
||
{refs.map((r) => (
|
||
<option key={r} value={r} />
|
||
))}
|
||
</datalist>
|
||
<input
|
||
aria-label={t("compareA")}
|
||
data-testid="compare-a"
|
||
list="compare-refs-list"
|
||
value={a}
|
||
maxLength={200}
|
||
onChange={(e) => setA(e.target.value)}
|
||
className={input}
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={t("swap")}
|
||
title={t("swap")}
|
||
onClick={() => {
|
||
setA(b);
|
||
setB(a);
|
||
setAsked({ a: asked.b, b: asked.a });
|
||
}}
|
||
className="shrink-0 rounded p-1 text-fg-secondary hover:bg-surface-2"
|
||
>
|
||
<ArrowLeftRight className="h-3.5 w-3.5" />
|
||
</button>
|
||
<input
|
||
aria-label={t("compareB")}
|
||
data-testid="compare-b"
|
||
list="compare-refs-list"
|
||
value={b}
|
||
maxLength={200}
|
||
onChange={(e) => setB(e.target.value)}
|
||
className={input}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="shrink-0 rounded bg-accent-solid px-2 py-0.5 text-[11px] text-white hover:bg-accent-solid-hover"
|
||
>
|
||
{t("compare")}
|
||
</button>
|
||
<select
|
||
data-testid="compare-mode"
|
||
value={mode}
|
||
onChange={(e) => setMode(e.target.value === "two" ? "two" : "three")}
|
||
className="min-w-0 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
|
||
>
|
||
<option value="three">{t("modeThree")}</option>
|
||
<option value="two">{t("modeTwo")}</option>
|
||
</select>
|
||
</form>
|
||
{error && (
|
||
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
|
||
{error}
|
||
</p>
|
||
)}
|
||
{!result && !error && (
|
||
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
|
||
)}
|
||
{result && (
|
||
<>
|
||
<div className="mb-1.5 flex flex-wrap gap-1 rounded border border-border bg-surface-2 p-0.5">
|
||
{tabBtn("files", t("tabFiles", { count: result.files.length }))}
|
||
{tabBtn("onlyA", t("tabOnly", { name: asked.a, count: result.onlyA.length }))}
|
||
{tabBtn("onlyB", t("tabOnly", { name: asked.b, count: result.onlyB.length }))}
|
||
</div>
|
||
{tab === "files" && !file && (
|
||
<ul data-testid="compare-files" className="min-h-0 flex-1 overflow-auto">
|
||
{result.files.length === 0 && (
|
||
<li className="text-[12px] text-fg-muted">{t("noDifference")}</li>
|
||
)}
|
||
{result.files.map((f) => (
|
||
<li key={f.path}>
|
||
<button
|
||
type="button"
|
||
disabled={fileLoading}
|
||
onClick={() => openFile(f.path, f.from)}
|
||
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] 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.from ? `${f.from} → ${f.path}` : f.path}
|
||
</span>
|
||
<span className="shrink-0 font-mono">
|
||
<span className="text-status-success">+{f.added}</span>{" "}
|
||
<span className="text-status-danger">−{f.deleted}</span>
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
{result.filesTruncated && (
|
||
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
|
||
)}
|
||
</ul>
|
||
)}
|
||
{tab === "files" && file && (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<button
|
||
type="button"
|
||
onClick={() => setFile(null)}
|
||
className="mb-1 self-start rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
|
||
>
|
||
{t("backToFiles")}
|
||
</button>
|
||
{file.secret ? (
|
||
<p className="text-[12px] text-fg-muted">{t("secretFile")}</p>
|
||
) : file.hunks?.length ? (
|
||
<LaneDiffView file={file} layout="unified" fill />
|
||
) : (
|
||
<p className="text-[12px] text-fg-muted">{t("noTextChange")}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
{tab !== "files" && (
|
||
<ul className="min-h-0 flex-1 overflow-auto">
|
||
{commits?.length === 0 && (
|
||
<li className="text-[12px] text-fg-muted">{t("noCommitsSide")}</li>
|
||
)}
|
||
{commits?.map((c) => (
|
||
<li key={c.sha}>
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpenCommit(c.sha)}
|
||
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] hover:bg-surface-2"
|
||
>
|
||
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
|
||
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
|
||
{c.subject}
|
||
</span>
|
||
<span className="min-w-0 max-w-[30%] truncate text-fg-muted">{c.author}</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
{result.commitsTruncated && (
|
||
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
|
||
)}
|
||
</ul>
|
||
)}
|
||
</>
|
||
)}
|
||
</GitModal>
|
||
);
|
||
}
|