752 lines
28 KiB
TypeScript
752 lines
28 KiB
TypeScript
/**
|
|
* @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>
|
|
);
|
|
}
|