feat(git): history graph with search, commit details, file history, blame/browse at a revision, compare refs, and multi-commit cherry-pick/revert with preview, pause banner and undo
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @file Blame / annotate at any revision (JetBrains' Annotate, GitLens'
|
||||
* blame): the file's lines with, on the first line of each run, the commit
|
||||
* that last changed it — short sha, author, relative date, summary. A commit
|
||||
* opens its whole change; "Blame before this change" re-reads the file at
|
||||
* that commit's parent (`previous`). Rows are windowed, so a 20 000-line
|
||||
* file scrolls as fast as a short one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { BlameAt } from "../../../lib/gitHistory";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
import { GitModal } from "./GitModal";
|
||||
|
||||
const ROW = 18;
|
||||
const OVERSCAN = 40;
|
||||
|
||||
export function BlameView({
|
||||
target,
|
||||
path,
|
||||
rev,
|
||||
onClose,
|
||||
onOpenCommit,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
path: string;
|
||||
/** A commit, or `work` for the file on disk. */
|
||||
rev: string;
|
||||
onClose: () => void;
|
||||
onOpenCommit: (sha: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
const [at, setAt] = useState({ path, rev });
|
||||
const [blame, setBlame] = useState<BlameAt | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setBlame(null);
|
||||
setError(null);
|
||||
void api.lanes.gitHistory
|
||||
.blameAt(target, at.path, at.rev)
|
||||
.then((r) => live && (r.available ? setBlame(r) : setError(t("noBlame"))))
|
||||
.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
|
||||
}, [at.path, at.rev]);
|
||||
|
||||
// Shade alternate commit runs so a run reads as one block.
|
||||
const runs = useMemo(() => {
|
||||
const starts: boolean[] = [];
|
||||
const band: number[] = [];
|
||||
let b = 0;
|
||||
blame?.lines.forEach((l, i) => {
|
||||
const start = i === 0 || blame.lines[i - 1]!.sha !== l.sha;
|
||||
if (start && i) b++;
|
||||
starts.push(start);
|
||||
band.push(b);
|
||||
});
|
||||
return { starts, band };
|
||||
}, [blame]);
|
||||
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [top, setTop] = useState(0);
|
||||
const height = 800;
|
||||
const n = blame?.lines.length ?? 0;
|
||||
const first = Math.max(0, Math.floor(top / ROW) - OVERSCAN);
|
||||
const last = Math.min(n, Math.ceil((top + height) / ROW) + OVERSCAN);
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
tall
|
||||
testId="blame-view"
|
||||
onClose={onClose}
|
||||
title={
|
||||
<span>
|
||||
{t("blameTitle", { path: at.path })}{" "}
|
||||
<span className="font-mono text-fg-muted">
|
||||
@ {at.rev === "work" ? t("workingTree") : at.rev.slice(0, 7)}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{error && <p className="text-[12px] text-status-danger [overflow-wrap:anywhere]">{error}</p>}
|
||||
{!blame && !error && (
|
||||
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
|
||||
)}
|
||||
{blame && (
|
||||
<div
|
||||
ref={box}
|
||||
onScroll={(e) => setTop(e.currentTarget.scrollTop)}
|
||||
className="min-h-0 flex-1 overflow-auto font-mono text-[11px] leading-[18px]"
|
||||
>
|
||||
<div style={{ height: n * ROW, position: "relative", minWidth: "100%" }}>
|
||||
{blame.lines.slice(first, last).map((l, k) => {
|
||||
const i = first + k;
|
||||
const c = l.sha ? blame.commits[l.sha] : null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
data-testid="blame-line"
|
||||
style={{ position: "absolute", top: i * ROW, height: ROW, left: 0, right: 0 }}
|
||||
className={`flex ${runs.band[i]! % 2 ? "bg-surface-2/60" : ""}`}
|
||||
>
|
||||
<span className="flex w-[16rem] shrink-0 items-center gap-1 overflow-hidden border-r border-border-light pr-1 text-fg-muted">
|
||||
{runs.starts[i] &&
|
||||
(c && l.sha ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenCommit(l.sha!)}
|
||||
title={`${c.summary ?? ""}\n${c.author ?? ""} · ${c.date ? new Date(c.date).toLocaleString() : ""}`}
|
||||
className="shrink-0 text-accent hover:underline"
|
||||
>
|
||||
{c.short}
|
||||
</button>
|
||||
<span className="min-w-0 truncate font-sans" title={c.author}>
|
||||
{c.author}
|
||||
</span>
|
||||
<span className="shrink-0 font-sans">
|
||||
{c.date ? timeAgo(c.date, true) : ""}
|
||||
</span>
|
||||
{c.previous && (
|
||||
<button
|
||||
type="button"
|
||||
title={t("blameBefore")}
|
||||
aria-label={t("blameBefore")}
|
||||
onClick={() => setAt({ path: at.path, rev: c.previous! })}
|
||||
className="shrink-0 font-sans hover:text-fg-primary"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="font-sans italic">{t("notCommitted")}</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="w-10 shrink-0 select-none pr-1 text-right text-fg-muted">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="whitespace-pre pl-1 text-fg-primary">{l.text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @file Browse files at a revision (GitHub's "Browse files", GitLens' "Open
|
||||
* files at revision"): a read-only tree of the workspace folder as it was at
|
||||
* one commit — folders open on a click, read lazily — and the picked file's
|
||||
* text (or image) beside it, with Blame at that commit. Every read goes
|
||||
* through the server's own guards: a secret file is neither listed nor
|
||||
* readable, a big file is capped, a binary one is named, not shown.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, File, Folder } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { TreeAtEntry } from "../../../lib/gitHistory";
|
||||
import type { LaneFileContent } from "../../../lib/types";
|
||||
import { LaneFileView } from "../LaneDiffView";
|
||||
import { GitModal } from "./GitModal";
|
||||
import { BlameView } from "./BlameView";
|
||||
|
||||
const IMAGE = /\.(png|jpe?g|gif|webp|avif|bmp|ico)$/i;
|
||||
|
||||
export function BrowseAtRevision({
|
||||
target,
|
||||
sha,
|
||||
short,
|
||||
onClose,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
sha: string;
|
||||
short: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
// Folder path → its entries; "" is the workspace folder.
|
||||
const [tree, setTree] = useState<Record<string, TreeAtEntry[]>>({});
|
||||
const [open, setOpen] = useState<Set<string>>(new Set([""]));
|
||||
const [file, setFile] = useState<string | null>(null);
|
||||
const [content, setContent] = useState<LaneFileContent | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [blame, setBlame] = useState(false);
|
||||
|
||||
const readDir = (dir: string) => {
|
||||
if (tree[dir]) return;
|
||||
void api.lanes.gitHistory
|
||||
.treeAt(target, sha, dir)
|
||||
.then((r) => r.available && setTree((m) => ({ ...m, [dir]: r.entries })))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
useEffect(() => {
|
||||
readDir("");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per commit
|
||||
}, [sha]);
|
||||
|
||||
const openFile = (p: string) => {
|
||||
setFile(p);
|
||||
setContent(null);
|
||||
setError(null);
|
||||
if (IMAGE.test(p)) return;
|
||||
void api.lanes
|
||||
.gitFile(target, p, sha)
|
||||
.then(setContent)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
|
||||
const toggleDir = (p: string) => {
|
||||
setOpen((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(p)) next.delete(p);
|
||||
else next.add(p);
|
||||
return next;
|
||||
});
|
||||
readDir(p);
|
||||
};
|
||||
|
||||
const rows = (dir: string, depth: number): ReactNode =>
|
||||
(tree[dir] ?? []).map((e) => (
|
||||
<li key={e.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (e.dir ? toggleDir(e.path) : e.submodule ? undefined : openFile(e.path))}
|
||||
title={e.path}
|
||||
style={{ paddingLeft: depth * 12 + 4 }}
|
||||
className={`flex w-full items-center gap-1 rounded py-0.5 pr-1 text-left text-[11px] hover:bg-surface-2 ${
|
||||
file === e.path ? "bg-surface-3 text-fg-primary" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{e.dir ? (
|
||||
open.has(e.path) ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" aria-hidden />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3 shrink-0" />
|
||||
)}
|
||||
{e.dir ? (
|
||||
<Folder className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
) : (
|
||||
<File className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{e.name}</span>
|
||||
</button>
|
||||
{e.dir && open.has(e.path) && <ul>{rows(e.path, depth + 1)}</ul>}
|
||||
</li>
|
||||
));
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
tall
|
||||
testId="browse-at-revision"
|
||||
onClose={onClose}
|
||||
title={t("browseTitle", { short })}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 sm:flex-row">
|
||||
<ul
|
||||
data-testid="browse-tree"
|
||||
className="max-h-[35vh] shrink-0 overflow-auto rounded border border-border-light p-1 sm:max-h-none sm:w-64"
|
||||
>
|
||||
{tree[""] ? (
|
||||
rows("", 0)
|
||||
) : (
|
||||
<li className="text-[11px] text-fg-muted">{t("lanes:changes.loading")}</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{file && (
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[11px]">
|
||||
<span className="min-w-0 flex-1 font-mono text-fg-primary [overflow-wrap:anywhere]">
|
||||
{file}
|
||||
</span>
|
||||
{!IMAGE.test(file) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlame(true)}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("blame")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!file && !error && <p className="text-[12px] text-fg-muted">{t("pickAFile")}</p>}
|
||||
{file && IMAGE.test(file) && (
|
||||
<img
|
||||
src={api.lanes.gitRawUrl(target, file, sha)}
|
||||
alt={file}
|
||||
className="max-h-full max-w-full self-start object-contain"
|
||||
/>
|
||||
)}
|
||||
{file && content && content.available && content.binary && (
|
||||
<p className="text-[12px] text-fg-muted">{t("binaryFile")}</p>
|
||||
)}
|
||||
{file && content && content.available && !content.binary && (
|
||||
<LaneFileView path={file} lines={content.lines} truncated={content.truncated} fill />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{blame && file && (
|
||||
<BlameView
|
||||
target={target}
|
||||
path={file}
|
||||
rev={sha}
|
||||
onClose={() => setBlame(false)}
|
||||
onOpenCommit={() => setBlame(false)}
|
||||
/>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @file The confirmation a cherry-pick or revert opens with, showing exactly
|
||||
* what will happen before anything does (the reference tools' preview): the
|
||||
* commits in the order git will apply them (numbered — read from the server,
|
||||
* which orders them the same way the action will), the branch they land on,
|
||||
* whether the working tree is clean, and what happens on a conflict. Confirm
|
||||
* is disabled, with the reason, when the server would refuse (a dirty tree, a
|
||||
* subfolder workspace, another operation open, a merge commit).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { PickPlan, PickResult } from "../../../lib/gitHistory";
|
||||
import { GitModal, ModalButton } from "./GitModal";
|
||||
|
||||
export function CherryPickDialog({
|
||||
target,
|
||||
op,
|
||||
shas,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
op: "cherry-pick" | "revert";
|
||||
shas: string[];
|
||||
onClose: () => void;
|
||||
onDone: (r: PickResult) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const [plan, setPlan] = useState<PickPlan | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void api.lanes.gitHistory
|
||||
.pickPlan(target, op, shas)
|
||||
.then((r) => live && (r.available ? setPlan(r) : setError(t("planUnavailable"))))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- one plan per dialog
|
||||
}, []);
|
||||
|
||||
const branch = plan?.branch ?? "HEAD";
|
||||
const blocker = !plan
|
||||
? null
|
||||
: plan.busy
|
||||
? t("blockBusy")
|
||||
: !plan.repoRoot
|
||||
? t("blockSubdir")
|
||||
: !plan.clean
|
||||
? t("blockDirty")
|
||||
: null;
|
||||
const n = plan?.commits.length ?? shas.length;
|
||||
const title =
|
||||
op === "revert" ? t("revertTitle", { count: n }) : t("pickTitle", { count: n, branch });
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
onDone(
|
||||
await api.lanes.gitHistory.pick(
|
||||
target,
|
||||
op,
|
||||
plan!.commits.map((c) => c.sha)
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
testId="pick-dialog"
|
||||
footer={
|
||||
<>
|
||||
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
|
||||
<ModalButton
|
||||
primary
|
||||
testId="pick-confirm"
|
||||
disabled={!plan || !!blocker || running}
|
||||
title={blocker ?? undefined}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{running
|
||||
? t("running")
|
||||
: op === "revert"
|
||||
? t("revertN", { count: n })
|
||||
: t("pickN", { count: n, branch })}
|
||||
</ModalButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2 text-[12px] text-fg-secondary">
|
||||
{!plan && !error && <p className="text-fg-muted">{t("planning")}</p>}
|
||||
{plan && (
|
||||
<>
|
||||
<p className="[overflow-wrap:anywhere]">
|
||||
{op === "revert"
|
||||
? t("revertIntro", { count: n, branch })
|
||||
: t("pickIntro", { count: n, branch })}
|
||||
</p>
|
||||
<ol
|
||||
data-testid="pick-plan"
|
||||
className="space-y-0.5 rounded border border-border-light bg-surface-0 p-1.5"
|
||||
>
|
||||
{plan.commits.map((c, i) => (
|
||||
<li key={c.sha} className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span className="w-5 shrink-0 text-right text-fg-muted">{i + 1}.</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">{c.short}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="flex items-center gap-1.5">
|
||||
{plan.clean && plan.repoRoot && !plan.busy ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-status-success" aria-hidden />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-status-warning" aria-hidden />
|
||||
)}
|
||||
<span data-testid="pick-tree-state" className="[overflow-wrap:anywhere]">
|
||||
{blocker ?? t("treeClean")}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-fg-muted">{t(op === "revert" ? "revertResult" : "pickResult")}</p>
|
||||
<p className="text-fg-muted">{t("onConflict", { op: t(`op.${op}`) })}</p>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @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} <{details.email}> · {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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file The History tab's commit rows, drawn like VS Code's Source Control
|
||||
* Graph / GitLens: the graph cell (lanes and merge edges from
|
||||
* `lib/commitGraph`), the ref badges (current branch, local and remote
|
||||
* branches, tags, a detached HEAD), the subject, author and relative date.
|
||||
* Colours are theme tokens (CSS variables), so a lane reads in every theme.
|
||||
* Long subjects and names truncate with the whole text in a tooltip.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cloud, GitBranch, Tag, Crosshair } from "lucide-react";
|
||||
import type { GraphRow } from "../../../lib/commitGraph";
|
||||
import type { RefBadge } from "../../../lib/gitHistory";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
|
||||
/** One fixed-height row per commit so the lanes join up row to row. */
|
||||
export const GRAPH_ROW = 30;
|
||||
const GRAPH_COL = 12;
|
||||
/** Columns drawn at most; a wider graph is clipped, not the row. */
|
||||
const MAX_COLS = 12;
|
||||
/** Theme tokens, cycled per lane. */
|
||||
const GRAPH_COLORS = [
|
||||
"rgb(var(--accent))",
|
||||
"rgb(var(--status-success))",
|
||||
"rgb(var(--status-warning))",
|
||||
"rgb(var(--syn-property))",
|
||||
"rgb(var(--status-danger))",
|
||||
"rgb(var(--syn-function))",
|
||||
"rgb(var(--kept))",
|
||||
"rgb(var(--diff-mod))",
|
||||
];
|
||||
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
|
||||
|
||||
export function GraphCell({
|
||||
row,
|
||||
width,
|
||||
merge,
|
||||
head,
|
||||
}: {
|
||||
row: GraphRow;
|
||||
width: number;
|
||||
/** A merge commit: a hollow node. */
|
||||
merge?: boolean;
|
||||
/** HEAD: a ringed node. */
|
||||
head?: boolean;
|
||||
}) {
|
||||
const cols = Math.min(width, MAX_COLS);
|
||||
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
|
||||
const y = (f: number) => f * GRAPH_ROW;
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
width={cols * GRAPH_COL}
|
||||
height={GRAPH_ROW}
|
||||
className="shrink-0 overflow-hidden"
|
||||
data-testid="lane-changes-graph"
|
||||
>
|
||||
{row.segs.map((g, i) => {
|
||||
const ym = (y(g.y1) + y(g.y2)) / 2;
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
|
||||
fill="none"
|
||||
stroke={hue(g.color)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{head && (
|
||||
<circle
|
||||
cx={x(row.col)}
|
||||
cy={y(0.5)}
|
||||
r={6}
|
||||
fill="none"
|
||||
stroke={hue(row.color)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={x(row.col)}
|
||||
cy={y(0.5)}
|
||||
r={3.5}
|
||||
fill={merge ? "rgb(var(--surface-1))" : hue(row.color)}
|
||||
stroke={hue(row.color)}
|
||||
strokeWidth={merge ? 1.5 : 0}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** One ref badge: the kind's icon, the name truncated, the full name on hover. */
|
||||
export function RefChip({ badge }: { badge: RefBadge }) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const Icon =
|
||||
badge.kind === "tag"
|
||||
? Tag
|
||||
: badge.kind === "remote"
|
||||
? Cloud
|
||||
: badge.kind === "head"
|
||||
? Crosshair
|
||||
: GitBranch;
|
||||
const tone = badge.current
|
||||
? "border-accent/60 bg-accent/15 text-accent font-semibold"
|
||||
: badge.kind === "tag"
|
||||
? "border-status-warning/50 text-status-warning"
|
||||
: badge.kind === "remote"
|
||||
? "border-border text-fg-muted"
|
||||
: badge.kind === "head"
|
||||
? "border-status-danger/50 text-status-danger"
|
||||
: "border-status-success/50 text-status-success";
|
||||
const label = t(`badge.${badge.kind}`, { name: badge.name });
|
||||
return (
|
||||
<span
|
||||
title={badge.current ? t("badge.current", { name: badge.name }) : label}
|
||||
data-testid="ref-badge"
|
||||
data-kind={badge.kind}
|
||||
className={`inline-flex max-w-[10rem] shrink items-center gap-0.5 rounded border px-1 text-[10px] leading-4 ${tone}`}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5 shrink-0" aria-hidden />
|
||||
<span className="truncate">{badge.name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Badges in the order the reference tools show them: HEAD's branch first. */
|
||||
export function sortBadges(badges: RefBadge[]): RefBadge[] {
|
||||
const rank = (b: RefBadge) =>
|
||||
b.current ? 0 : b.kind === "head" ? 1 : b.kind === "local" ? 2 : b.kind === "remote" ? 3 : 4;
|
||||
return [...badges].sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export interface GraphCommit {
|
||||
sha: string;
|
||||
short: string;
|
||||
subject: string;
|
||||
author: string;
|
||||
email?: string;
|
||||
date: string;
|
||||
parents: string[];
|
||||
badges?: RefBadge[];
|
||||
}
|
||||
|
||||
/** The text half of a history row (the graph cell is drawn beside it). */
|
||||
export const CommitText = memo(function CommitText({ c }: { c: GraphCommit }) {
|
||||
const badges = sortBadges(c.badges ?? []);
|
||||
const when = new Date(c.date);
|
||||
return (
|
||||
<span className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
{badges.slice(0, 4).map((b) => (
|
||||
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
|
||||
))}
|
||||
{badges.length > 4 && (
|
||||
<span
|
||||
className="shrink-0 text-[10px] text-fg-muted"
|
||||
title={badges
|
||||
.slice(4)
|
||||
.map((b) => b.name)
|
||||
.join(", ")}
|
||||
>
|
||||
+{badges.length - 4}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5 text-[10px] text-fg-muted">
|
||||
<span className="shrink-0 font-mono">{c.short}</span>
|
||||
<span className="min-w-0 truncate" title={c.email ? `${c.author} <${c.email}>` : c.author}>
|
||||
{c.author}
|
||||
</span>
|
||||
<span className="shrink-0" title={when.toLocaleString()}>
|
||||
{timeAgo(c.date)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file "Create branch here…" and "Create tag here…" from a History commit's
|
||||
* menu: a name (and, for a tag, an optional message making it annotated),
|
||||
* then git-refs.js's `branch-create {branch, from}` / `tag-create {tag,
|
||||
* message?, from}` with that commit's sha as `from` — the server confirms the
|
||||
* sha itself. Creating a branch never switches to it here. Announces
|
||||
* `agenthub:git-changed` so the other views of the workspace read again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { isSubmitEnter } from "../../../lib/keys";
|
||||
import { emitGitChanged } from "../../../lib/gitChanged";
|
||||
import { GitModal, ModalButton } from "./GitModal";
|
||||
|
||||
export function CreateBranchDialog({
|
||||
target,
|
||||
sha,
|
||||
short,
|
||||
kind = "branch",
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
sha: string;
|
||||
short: string;
|
||||
kind?: "branch" | "tag";
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const [name, setName] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const tag = kind === "tag";
|
||||
|
||||
const run = async () => {
|
||||
const ref = name.trim();
|
||||
if (!ref) return;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
const action = tag ? "tag-create" : "branch-create";
|
||||
try {
|
||||
await api.lanes.gitRefAction(
|
||||
target,
|
||||
action,
|
||||
tag
|
||||
? { tag: ref, from: sha, ...(message.trim() ? { message: message.trim() } : {}) }
|
||||
: { branch: ref, from: sha }
|
||||
);
|
||||
toast.success(t(tag ? "tagCreated" : "branchCreated", { name: ref, branch: ref, short }));
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setRunning(false);
|
||||
} finally {
|
||||
emitGitChanged(typeof target === "number" ? target : target.lane, action);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = (e: KeyboardEvent) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void run();
|
||||
}
|
||||
};
|
||||
const input =
|
||||
"rounded border border-border-light bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-primary";
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
testId="create-ref"
|
||||
onClose={onClose}
|
||||
title={t(tag ? "createTagTitle" : "createBranchTitle", { short })}
|
||||
footer={
|
||||
<>
|
||||
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
|
||||
<ModalButton
|
||||
primary
|
||||
testId="create-ref-confirm"
|
||||
disabled={!name.trim() || running}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{t(tag ? "createTag" : "createBranch")}
|
||||
</ModalButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className="flex flex-col gap-1 text-[12px] text-fg-secondary">
|
||||
{t(tag ? "tagName" : "branchName")}
|
||||
<input
|
||||
autoFocus
|
||||
data-testid="create-ref-name"
|
||||
value={name}
|
||||
maxLength={200}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={submit}
|
||||
className={input}
|
||||
/>
|
||||
</label>
|
||||
{tag && (
|
||||
<label className="mt-2 flex flex-col gap-1 text-[12px] text-fg-secondary">
|
||||
{t("tagMessage")}
|
||||
<input
|
||||
data-testid="create-ref-message"
|
||||
value={message}
|
||||
maxLength={1000}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={submit}
|
||||
className={input}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<p className="mt-1.5 text-[11px] text-fg-muted">
|
||||
{t(tag ? "createTagHint" : "createBranchHint")}
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="mt-1.5 text-[12px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,10 @@
|
||||
* editor, reflog, rebase, interactive rebase, reset, force push, remotes): a
|
||||
* portal over the screen with `useDialog`'s focus contract, a title row with a
|
||||
* close button, a scrolling body and an optional footer. `wide` widens it for
|
||||
* the three-pane merge editor; on a phone every size is the full width.
|
||||
* the three-pane merge editor; `tall` gives a viewer (blame, browse at a
|
||||
* revision, compare refs) the full height and a column body its own scrolling
|
||||
* child can fill; on a phone every size is the full width. `ModalButton` is
|
||||
* the History tools' footer button over the same BTN classes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,6 +29,7 @@ export function GitModal({
|
||||
onClose,
|
||||
testId,
|
||||
wide = false,
|
||||
tall = false,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
@@ -33,6 +37,8 @@ export function GitModal({
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
wide?: boolean;
|
||||
/** A viewer: the full height, and a flex-column body. */
|
||||
tall?: boolean;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
@@ -55,8 +61,8 @@ export function GitModal({
|
||||
data-testid={testId}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`flex max-h-[92vh] w-full flex-col rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 ${
|
||||
wide ? "max-w-6xl" : "max-w-lg"
|
||||
}`}
|
||||
wide || tall ? "max-w-6xl" : "max-w-lg"
|
||||
} ${tall ? "h-[92vh]" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<h2
|
||||
@@ -74,7 +80,11 @@ export function GitModal({
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-2 text-xs text-fg-secondary">
|
||||
<div
|
||||
className={`min-h-0 flex-1 px-3 py-2 text-xs text-fg-secondary ${
|
||||
tall ? "flex flex-col overflow-hidden" : "overflow-y-auto"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
@@ -109,3 +119,35 @@ export function Warn({ children, testId }: { children: ReactNode; testId?: strin
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A footer button: plain, `primary` (filled) or `danger` (red outline). */
|
||||
export function ModalButton({
|
||||
onClick,
|
||||
children,
|
||||
primary = false,
|
||||
danger = false,
|
||||
disabled = false,
|
||||
testId,
|
||||
title,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={primary ? BTN_PRIMARY : danger ? BTN_DANGER : BTN}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* @file The Changes panel's History tab, shaped like VS Code's Source Control
|
||||
* Graph, GitLens and the JetBrains git log: a commit graph (lanes and merge
|
||||
* edges, ref badges) read page by page and drawn windowed, so a repo with
|
||||
* tens of thousands of commits stays responsive; a search box (message, a SHA
|
||||
* prefix, `author:`, `path:`) and an all-branches switch; a details pane for
|
||||
* the focused commit; multi-select (checkbox, Ctrl/⌘-click, Shift-click) with
|
||||
* "Cherry-pick N" / "Revert N" that open a preview of exactly what will run
|
||||
* (CherryPickDialog); the reference tools' commit context menu; and, after a
|
||||
* pick or revert, a result banner with Undo while that is safe (the server
|
||||
* refuses it once the commits are pushed or HEAD moved on).
|
||||
*
|
||||
* Every write still goes through the server's guards (git-pick.js); a
|
||||
* disabled button here is a courtesy.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitCompare, Search, X } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { LaneCommit } from "../../../lib/types";
|
||||
import { commitGraph } from "../../../lib/commitGraph";
|
||||
import { parseSearch, inListOrder, type PickResult } from "../../../lib/gitHistory";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { copyText } from "../../../lib/clipboard";
|
||||
import { Checkbox } from "../../Checkbox";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../../ContextMenu";
|
||||
import { GRAPH_ROW, GraphCell, CommitText } from "./CommitGraph";
|
||||
import { CommitDetails } from "./CommitDetails";
|
||||
import { CherryPickDialog } from "./CherryPickDialog";
|
||||
import { BrowseAtRevision } from "./BrowseAtRevision";
|
||||
import { CompareRefs } from "./CompareRefs";
|
||||
import { CreateBranchDialog } from "./CreateBranchDialog";
|
||||
|
||||
/** Commits one page reads. */
|
||||
export const PAGE = 100;
|
||||
/** Rows drawn beyond the visible ones, each side. */
|
||||
const OVERSCAN = 20;
|
||||
|
||||
type Op = "cherry-pick" | "revert";
|
||||
|
||||
export function HistoryView({
|
||||
target,
|
||||
branch,
|
||||
branches,
|
||||
historyRef,
|
||||
onHistoryRef,
|
||||
reread,
|
||||
disabled,
|
||||
headPushed,
|
||||
onViewCommit,
|
||||
onOpenFileAtCommit,
|
||||
onUndoCommit,
|
||||
onResetHere,
|
||||
onEditAfter,
|
||||
onPaused,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** The branch HEAD is on (null = detached / unknown). */
|
||||
branch: string | null;
|
||||
/** Local branches, for "History of branch". */
|
||||
branches: string[];
|
||||
historyRef: string | null;
|
||||
onHistoryRef: (ref: string | null) => void;
|
||||
/** Bumped by the panel's Refresh and after its own writes. */
|
||||
reread: number;
|
||||
/** A write is running, or a merge/pick is open: nothing new starts. */
|
||||
disabled: boolean;
|
||||
/** The branch's upstream already holds HEAD (Undo commit refused). */
|
||||
headPushed: boolean;
|
||||
onViewCommit: (sha: string) => void;
|
||||
onOpenFileAtCommit: (path: string, sha: string) => void;
|
||||
onUndoCommit: (c: LaneCommit) => void;
|
||||
/** "Reset branch to here…" — the rewrite tools' reset dialog (GitTools). */
|
||||
onResetHere: (c: LaneCommit) => void;
|
||||
/** "Edit commits after this one…" — the interactive rebase (GitTools). */
|
||||
onEditAfter: (sha: string) => void;
|
||||
/** A pick/revert stopped on a conflict: the overview resolves it. */
|
||||
onPaused: () => void;
|
||||
/** History changed (a pick, revert or undo): re-read everything. */
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes", "gitTools"]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState(() => parseSearch(""));
|
||||
const [all, setAll] = useState(false);
|
||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [focused, setFocused] = useState<string | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const anchor = useRef<string | null>(null);
|
||||
const [dialog, setDialog] = useState<{ op: Op; shas: string[] } | null>(null);
|
||||
const [browse, setBrowse] = useState<{ sha: string; short: string } | null>(null);
|
||||
const [compare, setCompare] = useState<{ a: string; b: string } | null>(null);
|
||||
const [newRef, setNewRef] = useState<{
|
||||
sha: string;
|
||||
short: string;
|
||||
kind: "branch" | "tag";
|
||||
} | null>(null);
|
||||
const [lastPick, setLastPick] = useState<(PickResult & { op: Op }) | null>(null);
|
||||
const menu = useContextMenu<LaneCommit>();
|
||||
|
||||
// The search box applies after a pause in typing, not on every key.
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setFilter(parseSearch(search)), 300);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [search]);
|
||||
|
||||
const filtered = !!(filter.q || filter.author || filter.path);
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
...(all ? { all: true } : historyRef ? { ref: historyRef } : {}),
|
||||
...(filter.q ? { q: filter.q } : {}),
|
||||
...(filter.author ? { author: filter.author } : {}),
|
||||
...(filter.path ? { path: filter.path } : {}),
|
||||
}),
|
||||
[all, historyRef, filter]
|
||||
);
|
||||
const queryKey = JSON.stringify(query);
|
||||
|
||||
// The first page whenever the question changes; a reply for an older
|
||||
// question is dropped.
|
||||
const seq = useRef(0);
|
||||
useEffect(() => {
|
||||
const mine = ++seq.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void api.lanes
|
||||
.gitLog(target, { limit: PAGE, ...query })
|
||||
.then((r) => {
|
||||
if (seq.current !== mine) return;
|
||||
setCommits(r.available ? r.commits : []);
|
||||
setHasMore(!!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
|
||||
}, [queryKey, reread]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore || !commits) return;
|
||||
const mine = ++seq.current;
|
||||
setLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, { limit: PAGE, skip: commits.length, ...query })
|
||||
.then((r) => {
|
||||
if (seq.current !== mine) return;
|
||||
setCommits((prev) => {
|
||||
const seen = new Set((prev ?? []).map((c) => c.sha));
|
||||
return [...(prev ?? []), ...r.commits.filter((c) => !seen.has(c.sha))];
|
||||
});
|
||||
setHasMore(!!r.hasMore);
|
||||
})
|
||||
.catch(
|
||||
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
|
||||
)
|
||||
.finally(() => seq.current === mine && setLoading(false));
|
||||
}, [loading, hasMore, commits, target, query]);
|
||||
|
||||
const list = useMemo(() => commits ?? [], [commits]);
|
||||
// A search result is not a connected history: dots, no lanes.
|
||||
const graph = useMemo(
|
||||
() => commitGraph(filtered ? list.map((c) => ({ sha: c.sha, parents: [] })) : list),
|
||||
[list, filtered]
|
||||
);
|
||||
const byShaIndex = useMemo(() => new Map(list.map((c, i) => [c.sha, i])), [list]);
|
||||
|
||||
// Which loaded commits HEAD already has: Revert applies to those,
|
||||
// Cherry-pick to the rest. On HEAD's own history, all of them.
|
||||
const onHead = useMemo(() => {
|
||||
if (!all && !historyRef) return null;
|
||||
const head = list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"));
|
||||
const set = new Set<string>();
|
||||
if (!head) return set;
|
||||
const stack = [head.sha];
|
||||
while (stack.length) {
|
||||
const s = stack.pop()!;
|
||||
if (set.has(s) || !byShaIndex.has(s)) continue;
|
||||
set.add(s);
|
||||
stack.push(...(list[byShaIndex.get(s)!]!.parents ?? []));
|
||||
}
|
||||
return set;
|
||||
}, [all, historyRef, list, byShaIndex]);
|
||||
// The commit HEAD is on: Edit commits after it has nothing to edit.
|
||||
const headSha = useMemo(
|
||||
() =>
|
||||
list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"))?.sha ??
|
||||
(!all && !historyRef ? list[0]?.sha : undefined),
|
||||
[list, all, historyRef]
|
||||
);
|
||||
const isOnHead = useCallback((sha: string) => (onHead ? onHead.has(sha) : true), [onHead]);
|
||||
|
||||
// ---- selection -------------------------------------------------------
|
||||
const clickRow = (e: MouseEvent, c: LaneCommit) => {
|
||||
setFocused(c.sha);
|
||||
if (e.shiftKey && anchor.current && byShaIndex.has(anchor.current)) {
|
||||
const a = byShaIndex.get(anchor.current)!;
|
||||
const b = byShaIndex.get(c.sha)!;
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||
setPicked(new Set(list.slice(lo, hi + 1).map((x) => x.sha)));
|
||||
return;
|
||||
}
|
||||
anchor.current = c.sha;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
toggle(c.sha);
|
||||
return;
|
||||
}
|
||||
setPicked(new Set());
|
||||
};
|
||||
const toggle = (sha: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sha)) next.delete(sha);
|
||||
else next.add(sha);
|
||||
return next;
|
||||
});
|
||||
const selected = inListOrder(list, picked);
|
||||
const mergeIn = selected.some((c) => c.parents.length > 1);
|
||||
const allOnHead = selected.length > 0 && selected.every((c) => isOnHead(c.sha));
|
||||
const noneOnHead = selected.length > 0 && selected.every((c) => !isOnHead(c.sha));
|
||||
const target_branch = branch ?? "HEAD";
|
||||
|
||||
const openPick = (op: Op, shas: string[]) => setDialog({ op, shas });
|
||||
|
||||
const afterPick = (op: Op, r: PickResult) => {
|
||||
setDialog(null);
|
||||
setPicked(new Set());
|
||||
if (r.paused) {
|
||||
toast.error(
|
||||
r.empty
|
||||
? t("pausedEmpty", { op: t(`op.${op}`) })
|
||||
: t("pausedConflicts", { op: t(`op.${op}`), count: r.conflicts.length })
|
||||
);
|
||||
onPaused();
|
||||
return;
|
||||
}
|
||||
const n = r.commits?.length ?? 0;
|
||||
toast.success(
|
||||
t(op === "revert" ? "revertedN" : "pickedN", { count: n, branch: target_branch })
|
||||
);
|
||||
if (r.from && r.to && n) setLastPick({ ...r, op });
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const undoPick = async () => {
|
||||
if (!lastPick?.from || !lastPick.to) return;
|
||||
const n = lastPick.commits?.length ?? 0;
|
||||
const ok = await confirmDialog({
|
||||
title: t("undoTitle", { op: t(`op.${lastPick.op}`) }),
|
||||
message: t("undoBody", { count: n, branch: target_branch }),
|
||||
confirmLabel: t("undo"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.lanes.gitHistory.act(target, "undo-pick", {
|
||||
from: lastPick.from,
|
||||
to: lastPick.to,
|
||||
confirm: true,
|
||||
});
|
||||
toast.success(t("undone", { count: n }));
|
||||
setLastPick(null);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const copyPatch = async (c: LaneCommit) => {
|
||||
try {
|
||||
const r = await api.lanes.gitHistory.patch(target, c.sha);
|
||||
if (!r.available) return void toast.error(t("noPatch"));
|
||||
if (r.omitted.length) toast.info(t("patchOmitted", { count: r.omitted.length }));
|
||||
await copyText(r.patch);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- windowing -------------------------------------------------------
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [top, setTop] = useState(0);
|
||||
const [height, setHeight] = useState(600);
|
||||
useEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setHeight(el.clientHeight || 600));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const first = Math.max(0, Math.floor(top / GRAPH_ROW) - OVERSCAN);
|
||||
const last = Math.min(list.length, Math.ceil((top + height) / GRAPH_ROW) + OVERSCAN);
|
||||
const onScroll = () => {
|
||||
const el = scroller.current;
|
||||
if (!el) return;
|
||||
setTop(el.scrollTop);
|
||||
// Near the end: the next page, like an endless log.
|
||||
if (el.scrollTop + el.clientHeight > list.length * GRAPH_ROW - GRAPH_ROW * 10) loadMore();
|
||||
};
|
||||
|
||||
const focusedCommit = focused ? list.find((c) => c.sha === focused) : undefined;
|
||||
const menuItem = menu.menu?.item;
|
||||
const menuMulti = !!menuItem && picked.has(menuItem.sha) && picked.size > 1;
|
||||
const pick = (fn: () => void) => () => {
|
||||
menu.close();
|
||||
fn();
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="lane-changes-history" className="flex min-h-0 flex-1 flex-col gap-1.5">
|
||||
{/* Toolbar: search, all branches, branch, compare. Wraps on a narrow panel. */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-1 text-[11px]">
|
||||
<label className="flex min-w-[10rem] flex-1 items-center gap-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5">
|
||||
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
<input
|
||||
data-testid="history-search"
|
||||
value={search}
|
||||
maxLength={300}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
aria-label={t("searchLabel")}
|
||||
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary outline-none placeholder:text-fg-muted"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("clearSearch")}
|
||||
onClick={() => setSearch("")}
|
||||
className="shrink-0 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={all}
|
||||
onChange={setAll}
|
||||
label={t("allBranches")}
|
||||
labelClassName="text-[11px] text-fg-secondary"
|
||||
/>
|
||||
{!all && branches.length > 1 && (
|
||||
<label className="flex min-w-0 items-center gap-1 text-fg-secondary">
|
||||
<span className="shrink-0">{t("lanes:changes.historyOf")}</span>
|
||||
<select
|
||||
data-testid="lane-changes-history-ref"
|
||||
value={historyRef ?? ""}
|
||||
onChange={(e) => onHistoryRef(e.target.value || null)}
|
||||
className="min-w-0 max-w-[12rem] truncate rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
|
||||
>
|
||||
<option value="">{target_branch}</option>
|
||||
{branches
|
||||
.filter((b) => b !== branch)
|
||||
.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="history-compare"
|
||||
onClick={() =>
|
||||
setCompare({
|
||||
a: target_branch,
|
||||
b: historyRef ?? branches.find((b) => b !== branch) ?? target_branch,
|
||||
})
|
||||
}
|
||||
className="flex shrink-0 items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
<GitCompare className="h-3 w-3" aria-hidden />
|
||||
{t("compare")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastPick && (
|
||||
<div
|
||||
data-testid="pick-result"
|
||||
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-status-success/40 bg-status-success/10 px-2 py-1 text-[11px] text-status-success"
|
||||
>
|
||||
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
|
||||
{t(lastPick.op === "revert" ? "revertedN" : "pickedN", {
|
||||
count: lastPick.commits?.length ?? 0,
|
||||
branch: target_branch,
|
||||
})}{" "}
|
||||
<span className="font-mono">
|
||||
{(lastPick.commits ?? []).map((c) => c.short).join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-undo"
|
||||
disabled={disabled}
|
||||
title={t("undoHint")}
|
||||
onClick={() => void undoPick()}
|
||||
className="shrink-0 rounded border border-status-success/50 px-1.5 py-0.5 hover:bg-status-success/15 disabled:opacity-40"
|
||||
>
|
||||
{t("undo")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("dismiss")}
|
||||
onClick={() => setLastPick(null)}
|
||||
className="shrink-0 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div
|
||||
data-testid="history-selection"
|
||||
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-accent/40 bg-accent/10 px-2 py-1 text-[11px] text-fg-primary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("selectedN", { count: selected.length })}</span>
|
||||
{noneOnHead && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="history-pick-selected"
|
||||
disabled={disabled || mergeIn}
|
||||
title={mergeIn ? t("mergeCommitRefused") : undefined}
|
||||
onClick={() =>
|
||||
openPick(
|
||||
"cherry-pick",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
}
|
||||
className="shrink-0 rounded bg-accent-solid px-1.5 py-0.5 text-white hover:bg-accent-solid-hover disabled:opacity-40"
|
||||
>
|
||||
{t("pickN", { count: selected.length, branch: target_branch })}
|
||||
</button>
|
||||
)}
|
||||
{allOnHead && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="history-revert-selected"
|
||||
disabled={disabled || mergeIn}
|
||||
title={mergeIn ? t("mergeCommitRefused") : undefined}
|
||||
onClick={() =>
|
||||
openPick(
|
||||
"revert",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
}
|
||||
className="shrink-0 rounded border border-border px-1.5 py-0.5 hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{t("revertN", { count: selected.length })}
|
||||
</button>
|
||||
)}
|
||||
{!noneOnHead && !allOnHead && (
|
||||
<span className="text-fg-muted">{t("mixedSelection")}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicked(new Set())}
|
||||
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("clearSelection")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scroller}
|
||||
onScroll={onScroll}
|
||||
data-testid="history-list"
|
||||
className="min-h-[8rem] flex-1 overflow-y-auto"
|
||||
>
|
||||
{commits === null ? (
|
||||
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
|
||||
) : list.length === 0 ? (
|
||||
<p className="px-1 text-[11px] text-fg-muted">
|
||||
{filtered ? t("noMatches") : t("lanes:changes.noCommits")}
|
||||
</p>
|
||||
) : (
|
||||
<div role="list" style={{ height: list.length * GRAPH_ROW, position: "relative" }}>
|
||||
{list.slice(first, last).map((c, k) => {
|
||||
const i = first + k;
|
||||
const isPicked = picked.has(c.sha);
|
||||
const head = (c.badges ?? []).some((b) => b.current || b.kind === "head");
|
||||
return (
|
||||
<div
|
||||
key={c.sha}
|
||||
role="listitem"
|
||||
data-sha={c.sha}
|
||||
aria-selected={isPicked || focused === c.sha}
|
||||
onContextMenu={(e) => menu.open(e, c)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: i * GRAPH_ROW,
|
||||
height: GRAPH_ROW,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}
|
||||
className={`group flex items-center gap-1 rounded px-1 ${
|
||||
isPicked
|
||||
? "bg-accent/15"
|
||||
: focused === c.sha
|
||||
? "bg-surface-3"
|
||||
: "hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isPicked}
|
||||
onChange={() => {
|
||||
// A tick is an anchor too: Shift-click extends from it.
|
||||
anchor.current = c.sha;
|
||||
toggle(c.sha);
|
||||
}}
|
||||
title={t("selectCommit", { short: c.short })}
|
||||
className={isPicked || picked.size ? "" : "opacity-40 group-hover:opacity-100"}
|
||||
/>
|
||||
<GraphCell
|
||||
row={graph.rows[i]!}
|
||||
width={graph.width}
|
||||
merge={c.parents.length > 1}
|
||||
head={head}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => clickRow(e, c)}
|
||||
onDoubleClick={() => onViewCommit(c.sha)}
|
||||
className="flex h-full min-w-0 flex-1 items-center text-left"
|
||||
>
|
||||
<CommitText c={c} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-history-more"
|
||||
disabled={loading}
|
||||
onClick={loadMore}
|
||||
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{focusedCommit && (
|
||||
<CommitDetails
|
||||
key={focusedCommit.sha}
|
||||
target={target}
|
||||
sha={focusedCommit.sha}
|
||||
onClose={() => setFocused(null)}
|
||||
onOpenCommit={(sha) => {
|
||||
setFocused(sha);
|
||||
const el = scroller.current?.querySelector(`[data-sha="${sha}"]`);
|
||||
(el as HTMLElement | null)?.scrollIntoView?.({ block: "nearest" });
|
||||
}}
|
||||
onViewChanges={() => onViewCommit(focusedCommit.sha)}
|
||||
onOpenFile={(path) => onOpenFileAtCommit(path, focusedCommit.sha)}
|
||||
onBrowse={() => setBrowse({ sha: focusedCommit.sha, short: focusedCommit.short })}
|
||||
onCompare={() => setCompare({ a: target_branch, b: focusedCommit.sha })}
|
||||
onCopyPatch={() => void copyPatch(focusedCommit)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{menu.menu && menuItem && (
|
||||
<ContextMenu
|
||||
x={menu.menu.x}
|
||||
y={menu.menu.y}
|
||||
onClose={menu.close}
|
||||
testId="lane-changes-commit-menu"
|
||||
>
|
||||
<MenuItem onSelect={pick(() => onViewCommit(menuItem.sha))}>
|
||||
{t("lanes:changes.viewCommit")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => setFocused(menuItem.sha))}>{t("showDetails")}</MenuItem>
|
||||
<MenuItem onSelect={pick(() => setBrowse({ sha: menuItem.sha, short: menuItem.short }))}>
|
||||
{t("browseFiles")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => setCompare({ a: target_branch, b: menuItem.sha }))}>
|
||||
{t("compareWithCurrent")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={pick(() => void copyText(menuItem.sha))}>
|
||||
{t("lanes:changes.copySha")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => void copyText(menuItem.subject))}>
|
||||
{t("lanes:changes.copySubject")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={menuItem.parents.length > 1}
|
||||
onSelect={pick(() => void copyPatch(menuItem))}
|
||||
>
|
||||
{t("copyPatch")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="history-menu-branch"
|
||||
disabled={disabled}
|
||||
onSelect={pick(() =>
|
||||
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "branch" })
|
||||
)}
|
||||
>
|
||||
{t("createBranchHere")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="history-menu-tag"
|
||||
disabled={disabled}
|
||||
onSelect={pick(() =>
|
||||
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "tag" })
|
||||
)}
|
||||
>
|
||||
{t("createTagHere")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{menuMulti ? (
|
||||
<>
|
||||
{noneOnHead && (
|
||||
<MenuItem
|
||||
disabled={disabled || mergeIn}
|
||||
onSelect={pick(() =>
|
||||
openPick(
|
||||
"cherry-pick",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
)}
|
||||
>
|
||||
{t("pickN", { count: selected.length, branch: target_branch })}
|
||||
</MenuItem>
|
||||
)}
|
||||
{allOnHead && (
|
||||
<MenuItem
|
||||
disabled={disabled || mergeIn}
|
||||
onSelect={pick(() =>
|
||||
openPick(
|
||||
"revert",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
)}
|
||||
>
|
||||
{t("revertN", { count: selected.length })}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
) : isOnHead(menuItem.sha) ? (
|
||||
<>
|
||||
<MenuItem
|
||||
disabled={disabled || menuItem.parents.length !== 1}
|
||||
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
|
||||
testId="lane-changes-menu-revert"
|
||||
onSelect={pick(() => openPick("revert", [menuItem.sha]))}
|
||||
>
|
||||
{t("lanes:changes.revertCommit")}
|
||||
</MenuItem>
|
||||
{!all && !historyRef && list[0]?.sha === menuItem.sha && (
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={disabled || headPushed || menuItem.parents.length !== 1}
|
||||
title={headPushed ? t("lanes:changes.undoCommitPushed") : undefined}
|
||||
testId="lane-changes-menu-undo-commit"
|
||||
onSelect={pick(() => onUndoCommit(menuItem))}
|
||||
>
|
||||
{t("lanes:changes.undoCommit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* History rewrites (GitTools): each opens a dialog that asks first. */}
|
||||
<MenuItem
|
||||
disabled={disabled}
|
||||
testId="lane-changes-menu-reset"
|
||||
onSelect={pick(() => onResetHere(menuItem))}
|
||||
>
|
||||
{t("gitTools:menu.resetHere")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={disabled || menuItem.sha === headSha}
|
||||
testId="lane-changes-menu-interactive"
|
||||
onSelect={pick(() => onEditAfter(menuItem.sha))}
|
||||
>
|
||||
{t("gitTools:menu.interactiveHere")}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem
|
||||
disabled={disabled || menuItem.parents.length !== 1}
|
||||
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
|
||||
testId="lane-changes-menu-cherry-pick"
|
||||
onSelect={pick(() => openPick("cherry-pick", [menuItem.sha]))}
|
||||
>
|
||||
{t("lanes:changes.cherryPick", { branch: target_branch })}
|
||||
</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<CherryPickDialog
|
||||
target={target}
|
||||
op={dialog.op}
|
||||
shas={dialog.shas}
|
||||
onClose={() => setDialog(null)}
|
||||
onDone={(r) => afterPick(dialog.op, r)}
|
||||
/>
|
||||
)}
|
||||
{browse && (
|
||||
<BrowseAtRevision
|
||||
target={target}
|
||||
sha={browse.sha}
|
||||
short={browse.short}
|
||||
onClose={() => setBrowse(null)}
|
||||
/>
|
||||
)}
|
||||
{compare && (
|
||||
<CompareRefs
|
||||
target={target}
|
||||
initialA={compare.a}
|
||||
initialB={compare.b}
|
||||
refs={branches}
|
||||
onClose={() => setCompare(null)}
|
||||
onOpenCommit={(sha) => {
|
||||
setCompare(null);
|
||||
onViewCommit(sha);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{newRef && (
|
||||
<CreateBranchDialog
|
||||
target={target}
|
||||
sha={newRef.sha}
|
||||
short={newRef.short}
|
||||
kind={newRef.kind}
|
||||
onClose={() => setNewRef(null)}
|
||||
onDone={() => {
|
||||
setNewRef(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @file The banner a paused cherry-pick or revert shows above the Changes
|
||||
* panel (the reference tools' "sequencer" bar): "Cherry-pick paused at commit
|
||||
* X (2 of 5): resolve the conflicted files, then Continue — or Skip this
|
||||
* commit, or Abort to go back to where you were", with those three buttons.
|
||||
* Continue is disabled while a file is still conflicted; Skip and Abort throw
|
||||
* work away, so each takes a second click (`useArmed`) and sends
|
||||
* `confirm: true`. Reads GET /git/sequence; acts through git-pick.js.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { PickAction, PickSequence } from "../../../lib/gitHistory";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
|
||||
type Active = Extract<PickSequence, { active: true }>;
|
||||
|
||||
export function PickBanner({
|
||||
target,
|
||||
conflicts,
|
||||
reread,
|
||||
busy,
|
||||
onBusy,
|
||||
onDone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** The git facts' conflict count: a change re-reads the sequence. */
|
||||
conflicts: number;
|
||||
reread: number;
|
||||
busy: boolean;
|
||||
onBusy: (busy: boolean) => void;
|
||||
/** A button ran: re-read the facts and the changes. */
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const [seq, setSeq] = useState<Active | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { arm, disarm, isArmed } = useArmed();
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void api.lanes.gitHistory
|
||||
.sequence(target)
|
||||
.then((r) => live && setSeq(r.available && r.active ? r : null))
|
||||
.catch(() => live && setSeq(null));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
|
||||
}, [conflicts, reread]);
|
||||
|
||||
if (!seq) return null;
|
||||
const op = t(`op.${seq.operation}`);
|
||||
const step = seq.done + 1;
|
||||
const total = seq.done + 1 + seq.remaining.length;
|
||||
const cur = seq.current;
|
||||
const left = seq.conflicts.length;
|
||||
|
||||
const act = async (action: PickAction) => {
|
||||
disarm();
|
||||
onBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.gitHistory.act(
|
||||
target,
|
||||
action,
|
||||
action === "pick-continue" ? {} : { confirm: true }
|
||||
);
|
||||
if (action === "pick-abort") toast.success(t("aborted", { op }));
|
||||
else if (r.paused) toast.error(t("pausedConflicts", { op, count: r.conflicts.length }));
|
||||
else toast.success(t("sequenceDone", { op, count: r.commits?.length ?? 0 }));
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
onBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const btn =
|
||||
"shrink-0 rounded border px-1.5 py-0.5 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
return (
|
||||
<div
|
||||
data-testid="pick-banner"
|
||||
className="mb-2 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
|
||||
>
|
||||
<p className="[overflow-wrap:anywhere]">
|
||||
<strong>
|
||||
{cur
|
||||
? t("pausedAt", { op, short: cur.short, subject: cur.subject, step, total })
|
||||
: t("pausedBetween", { op, step, total })}
|
||||
</strong>{" "}
|
||||
{left
|
||||
? t("pausedHelpConflicts", { count: left })
|
||||
: cur
|
||||
? t("pausedHelpReady")
|
||||
: t("pausedHelpResume")}
|
||||
</p>
|
||||
{seq.remaining.length > 0 && (
|
||||
<p className="mt-0.5 text-fg-muted [overflow-wrap:anywhere]">
|
||||
{t("stillToApply", {
|
||||
list: seq.remaining.map((c) => c.short).join(", "),
|
||||
count: seq.remaining.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-continue"
|
||||
disabled={busy || left > 0}
|
||||
title={left ? t("continueBlocked") : undefined}
|
||||
onClick={() => void act("pick-continue")}
|
||||
className={`${btn} border-accent/40 bg-accent/15 text-accent hover:bg-accent/25`}
|
||||
>
|
||||
{t("continue")}
|
||||
</button>
|
||||
{cur && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-skip"
|
||||
disabled={busy}
|
||||
title={t("skipHint")}
|
||||
onClick={() => (isArmed("skip") ? void act("pick-skip") : arm("skip"))}
|
||||
className={`${btn} border-border text-fg-secondary hover:bg-surface-2`}
|
||||
>
|
||||
{isArmed("skip") ? t("skipConfirm") : t("skip")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-abort"
|
||||
disabled={busy}
|
||||
title={t("abortHint")}
|
||||
onClick={() => (isArmed("abort") ? void act("pick-abort") : arm("abort"))}
|
||||
className={`${btn} border-status-danger/40 text-status-danger hover:bg-status-danger/10`}
|
||||
>
|
||||
{isArmed("abort") ? t("abortConfirm", { op }) : t("abort")}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-1 whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @file Pins the History tab's graph view: rows with their graph cell and ref
|
||||
* badges, the search box turning `author:`/`path:` into the log's filters,
|
||||
* all branches, multi-select (checkbox and Shift-click) into a cherry-pick
|
||||
* preview that lists the commits in the server's order and runs only on
|
||||
* Confirm, a paused result handed to the overview, and Undo after a clean
|
||||
* pick (asked first, sent with the from/to the pick answered). Also the pure
|
||||
* `parseSearch` helper, and the commit menu's rewrite tools (Reset branch to
|
||||
* here, Edit commits after this one) and Create tag here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HistoryView } from "../HistoryView";
|
||||
import { api } from "../../../../lib/api";
|
||||
import { confirmDialog } from "../../../../lib/confirm";
|
||||
import { parseSearch } from "../../../../lib/gitHistory";
|
||||
import type { LaneCommit } from "../../../../lib/types";
|
||||
|
||||
vi.mock("../../../../lib/confirm", () => ({
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("../../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
gitLog: vi.fn(),
|
||||
changes: vi.fn(),
|
||||
gitRefAction: vi.fn(),
|
||||
gitHistory: {
|
||||
commit: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
pickPlan: vi.fn(),
|
||||
pick: vi.fn(),
|
||||
act: vi.fn(),
|
||||
compare: vi.fn(),
|
||||
treeAt: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const sha = (c: string) => c.repeat(40);
|
||||
const commit = (c: string, over: Partial<LaneCommit> = {}): LaneCommit => ({
|
||||
sha: sha(c),
|
||||
short: c.repeat(7),
|
||||
subject: `commit ${c}`,
|
||||
author: "Ann",
|
||||
date: "2026-10-01T00:00:00Z",
|
||||
refs: [],
|
||||
parents: [],
|
||||
badges: [],
|
||||
...over,
|
||||
});
|
||||
// other: c -> b (feature branch), main: a (HEAD)
|
||||
const LOG = [
|
||||
commit("c", { parents: [sha("b")], badges: [{ kind: "local", name: "feature" }] }),
|
||||
commit("b", { parents: [sha("a")] }),
|
||||
commit("a", {
|
||||
parents: [],
|
||||
badges: [
|
||||
{ kind: "local", name: "main", current: true },
|
||||
{ kind: "tag", name: "v1" },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const props = () => ({
|
||||
target: 7,
|
||||
branch: "main",
|
||||
branches: ["main", "feature"],
|
||||
historyRef: null,
|
||||
onHistoryRef: vi.fn(),
|
||||
reread: 0,
|
||||
disabled: false,
|
||||
headPushed: false,
|
||||
onViewCommit: vi.fn(),
|
||||
onOpenFileAtCommit: vi.fn(),
|
||||
onUndoCommit: vi.fn(),
|
||||
onResetHere: vi.fn(),
|
||||
onEditAfter: vi.fn(),
|
||||
onPaused: vi.fn(),
|
||||
onChanged: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitLog).mockReset().mockResolvedValue({ available: true, commits: LOG });
|
||||
vi.mocked(api.lanes.changes)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
range: "commit",
|
||||
base: null,
|
||||
baseRef: null,
|
||||
sha: sha("c"),
|
||||
context: 3,
|
||||
truncated: false,
|
||||
files: [],
|
||||
});
|
||||
vi.mocked(api.lanes.gitHistory.commit).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockReset();
|
||||
vi.mocked(api.lanes.gitHistory.act).mockReset();
|
||||
vi.mocked(api.lanes.gitHistory.pickPlan)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_t, op, shas) => ({
|
||||
available: true,
|
||||
op,
|
||||
// The server's order: oldest first for a pick.
|
||||
commits: [...shas].reverse().map((s) => ({
|
||||
sha: s,
|
||||
short: s.slice(0, 7),
|
||||
subject: `commit ${s[0]}`,
|
||||
author: "Ann",
|
||||
date: "",
|
||||
})),
|
||||
branch: "main",
|
||||
clean: true,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
}));
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
});
|
||||
|
||||
describe("parseSearch", () => {
|
||||
it("splits author: and path: from the message text", () => {
|
||||
expect(parseSearch('fix login author:ann path:"src/a b.ts"')).toEqual({
|
||||
q: "fix login",
|
||||
author: "ann",
|
||||
path: "src/a b.ts",
|
||||
});
|
||||
expect(parseSearch(" ")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HistoryView", () => {
|
||||
it("draws one graph cell per commit with its ref badges", async () => {
|
||||
render(<HistoryView {...props()} />);
|
||||
await screen.findByText("commit c");
|
||||
expect(screen.getAllByTestId("lane-changes-graph")).toHaveLength(3);
|
||||
const badges = screen.getAllByTestId("ref-badge");
|
||||
expect(badges.map((b) => b.textContent)).toEqual(["feature", "main", "v1"]);
|
||||
expect(badges[1]).toHaveAttribute("title", expect.stringMatching(/branch you are on/));
|
||||
});
|
||||
|
||||
it("searches with filters and reads every branch", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HistoryView {...props()} />);
|
||||
await screen.findByText("commit c");
|
||||
await user.type(screen.getByTestId("history-search"), "fix author:ann");
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||
limit: 100,
|
||||
q: "fix",
|
||||
author: "ann",
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
await user.click(screen.getByRole("checkbox", { name: "All branches" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||
limit: 100,
|
||||
all: true,
|
||||
q: "fix",
|
||||
author: "ann",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("multi-selects, previews the cherry-pick in order, and runs it only on Confirm", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = { ...props(), historyRef: "feature" };
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "cherry-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
from: sha("a"),
|
||||
to: sha("e"),
|
||||
commits: [
|
||||
{ sha: sha("d"), short: "ddddddd", subject: "commit b" },
|
||||
{ sha: sha("e"), short: "eeeeeee", subject: "commit c" },
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.gitHistory.act).mockResolvedValue({
|
||||
action: "undo-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
});
|
||||
render(<HistoryView {...p} />);
|
||||
await screen.findByText("commit c");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
|
||||
// Shift-click extends from the anchor (c) to b.
|
||||
fireEvent.click(screen.getByText("commit b"), { shiftKey: true });
|
||||
expect(screen.getByTestId("history-selection")).toHaveTextContent("2 commits selected");
|
||||
|
||||
await user.click(screen.getByTestId("history-pick-selected"));
|
||||
const plan = await screen.findByTestId("pick-plan");
|
||||
expect(
|
||||
within(plan)
|
||||
.getAllByRole("listitem")
|
||||
.map((li) => li.textContent)
|
||||
).toEqual(["1.bbbbbbbcommit b", "2.ccccccccommit c"]);
|
||||
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent("clean");
|
||||
expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByTestId("pick-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [sha("b"), sha("c")])
|
||||
);
|
||||
expect(p.onChanged).toHaveBeenCalled();
|
||||
|
||||
// Undo is offered, asked first, and sent with what the pick answered.
|
||||
await user.click(await screen.findByTestId("pick-undo"));
|
||||
expect(confirmDialog).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "undo-pick", {
|
||||
from: sha("a"),
|
||||
to: sha("e"),
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByTestId("pick-result")).toBeNull());
|
||||
});
|
||||
|
||||
it("refuses to start when the tree is dirty, and hands a paused pick to the overview", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = { ...props(), historyRef: "feature" };
|
||||
vi.mocked(api.lanes.gitHistory.pickPlan).mockResolvedValueOnce({
|
||||
available: true,
|
||||
op: "cherry-pick",
|
||||
commits: [{ sha: sha("c"), short: "ccccccc", subject: "commit c", author: "", date: "" }],
|
||||
branch: "main",
|
||||
clean: false,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
});
|
||||
render(<HistoryView {...p} />);
|
||||
await screen.findByText("commit c");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
|
||||
await user.click(screen.getByTestId("history-pick-selected"));
|
||||
await screen.findByTestId("pick-plan");
|
||||
expect(screen.getByTestId("pick-confirm")).toBeDisabled();
|
||||
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent(/uncommitted changes/);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "cherry-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: ["a.txt"],
|
||||
paused: true,
|
||||
});
|
||||
await user.click(screen.getByTestId("history-pick-selected"));
|
||||
await user.click(await screen.findByTestId("pick-confirm"));
|
||||
await waitFor(() => expect(p.onPaused).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("pick-result")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the details pane on a click and the full change on a double-click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = props();
|
||||
render(<HistoryView {...p} />);
|
||||
await user.click(await screen.findByText("commit b"));
|
||||
expect(await screen.findByTestId("commit-details")).toBeInTheDocument();
|
||||
expect(api.lanes.gitHistory.commit).toHaveBeenCalledWith(7, sha("b"));
|
||||
await user.dblClick(screen.getByText("commit c"));
|
||||
expect(p.onViewCommit).toHaveBeenCalledWith(sha("c"));
|
||||
});
|
||||
|
||||
it("keeps the rewrite tools in the commit menu and creates a tag at a commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = props();
|
||||
vi.mocked(api.lanes.gitRefAction).mockReset().mockResolvedValue({
|
||||
action: "tag-create",
|
||||
ok: true,
|
||||
output: "",
|
||||
});
|
||||
render(<HistoryView {...p} />);
|
||||
const menuOn = async (text: string) => {
|
||||
await user.pointer({ keys: "[MouseRight]", target: await screen.findByText(text) });
|
||||
return screen.getByTestId("lane-changes-commit-menu");
|
||||
};
|
||||
// HEAD's own commit (main, the current branch): nothing after it to edit.
|
||||
expect(
|
||||
within(await menuOn("commit a")).getByTestId("lane-changes-menu-interactive")
|
||||
).toBeDisabled();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(within(await menuOn("commit b")).getByText("Reset branch to here…"));
|
||||
expect(p.onResetHere).toHaveBeenCalledWith(expect.objectContaining({ sha: sha("b") }));
|
||||
await user.click(within(await menuOn("commit b")).getByText("Edit commits after this one…"));
|
||||
expect(p.onEditAfter).toHaveBeenCalledWith(sha("b"));
|
||||
|
||||
await user.click(within(await menuOn("commit a")).getByText("Create tag here…"));
|
||||
await user.type(screen.getByTestId("create-ref-name"), "v2");
|
||||
await user.click(screen.getByTestId("create-ref-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(7, "tag-create", {
|
||||
tag: "v2",
|
||||
from: sha("a"),
|
||||
})
|
||||
);
|
||||
expect(p.onChanged).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @file Pins the paused cherry-pick/revert banner: it names the commit it
|
||||
* stopped at and the step ("2 of 3"), Continue waits for every conflict to be
|
||||
* resolved, Skip and Abort each need a second click and only then send
|
||||
* `confirm: true`, and it draws nothing when no pick is open.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { PickBanner } from "../PickBanner";
|
||||
import { api } from "../../../../lib/api";
|
||||
|
||||
vi.mock("../../../../lib/api", () => ({
|
||||
api: { lanes: { gitHistory: { sequence: vi.fn(), act: vi.fn() } } },
|
||||
}));
|
||||
|
||||
const paused = (conflicts: string[]) => ({
|
||||
available: true as const,
|
||||
active: true as const,
|
||||
operation: "cherry-pick" as const,
|
||||
current: { sha: "b".repeat(40), short: "bbbbbbb", subject: "edit a" },
|
||||
remaining: [{ sha: "c".repeat(40), short: "ccccccc", subject: "add c" }],
|
||||
done: 1,
|
||||
startedFrom: "a".repeat(40),
|
||||
conflicts,
|
||||
});
|
||||
|
||||
const props = () => ({
|
||||
target: 7,
|
||||
conflicts: 1,
|
||||
reread: 0,
|
||||
busy: false,
|
||||
onBusy: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitHistory.act).mockReset().mockResolvedValue({
|
||||
action: "pick-continue",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
commits: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("PickBanner", () => {
|
||||
it("names the stop and its step, and Continue waits for the conflicts", async () => {
|
||||
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused(["a.txt"]));
|
||||
render(<PickBanner {...props()} />);
|
||||
const banner = await screen.findByTestId("pick-banner");
|
||||
expect(banner).toHaveTextContent('cherry-pick paused at bbbbbbb "edit a" (2 of 3)');
|
||||
expect(banner).toHaveTextContent("Still to apply: ccccccc");
|
||||
expect(screen.getByTestId("pick-continue")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("continues once resolved, and skips/aborts only on a second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused([]));
|
||||
const p = props();
|
||||
render(<PickBanner {...p} />);
|
||||
await user.click(await screen.findByTestId("pick-continue"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "pick-continue", {})
|
||||
);
|
||||
expect(p.onDone).toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId("pick-skip"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByTestId("pick-skip"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-skip", { confirm: true });
|
||||
|
||||
await user.click(screen.getByTestId("pick-abort"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(2);
|
||||
await user.click(screen.getByTestId("pick-abort"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-abort", { confirm: true });
|
||||
});
|
||||
|
||||
it("draws nothing when no pick is open", async () => {
|
||||
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue({ available: true, active: false });
|
||||
const { container } = render(<PickBanner {...props()} />);
|
||||
await waitFor(() => expect(api.lanes.gitHistory.sequence).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user