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

246 lines
9.2 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 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>
);
}