/** * @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ĩ */ 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(null); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [focused, setFocused] = useState(null); const [picked, setPicked] = useState>(new Set()); const anchor = useRef(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(); // 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(); 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(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 (
{/* Toolbar: search, all branches, branch, compare. Wraps on a narrow panel. */}
{!all && branches.length > 1 && ( )}
{lastPick && (
{t(lastPick.op === "revert" ? "revertedN" : "pickedN", { count: lastPick.commits?.length ?? 0, branch: target_branch, })}{" "} {(lastPick.commits ?? []).map((c) => c.short).join(", ")}
)} {selected.length > 0 && (
{t("selectedN", { count: selected.length })} {noneOnHead && ( )} {allOnHead && ( )} {!noneOnHead && !allOnHead && ( {t("mixedSelection")} )}
)} {error && (

{error}

)}
{commits === null ? (

{t("lanes:changes.loading")}

) : list.length === 0 ? (

{filtered ? t("noMatches") : t("lanes:changes.noCommits")}

) : (
{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 (
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" }`} > { // 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"} /> 1} head={head} />
); })}
)} {hasMore && ( )}
{focusedCommit && ( 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 && ( onViewCommit(menuItem.sha))}> {t("lanes:changes.viewCommit")} setFocused(menuItem.sha))}>{t("showDetails")} setBrowse({ sha: menuItem.sha, short: menuItem.short }))}> {t("browseFiles")} setCompare({ a: target_branch, b: menuItem.sha }))}> {t("compareWithCurrent")} void copyText(menuItem.sha))}> {t("lanes:changes.copySha")} void copyText(menuItem.subject))}> {t("lanes:changes.copySubject")} 1} onSelect={pick(() => void copyPatch(menuItem))} > {t("copyPatch")} setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "branch" }) )} > {t("createBranchHere")} setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "tag" }) )} > {t("createTagHere")} {menuMulti ? ( <> {noneOnHead && ( openPick( "cherry-pick", selected.map((c) => c.sha) ) )} > {t("pickN", { count: selected.length, branch: target_branch })} )} {allOnHead && ( openPick( "revert", selected.map((c) => c.sha) ) )} > {t("revertN", { count: selected.length })} )} ) : isOnHead(menuItem.sha) ? ( <> 1 ? t("mergeCommitRefused") : undefined} testId="lane-changes-menu-revert" onSelect={pick(() => openPick("revert", [menuItem.sha]))} > {t("lanes:changes.revertCommit")} {!all && !historyRef && list[0]?.sha === menuItem.sha && ( onUndoCommit(menuItem))} > {t("lanes:changes.undoCommit")} )} {/* History rewrites (GitTools): each opens a dialog that asks first. */} onResetHere(menuItem))} > {t("gitTools:menu.resetHere")} onEditAfter(menuItem.sha))} > {t("gitTools:menu.interactiveHere")} ) : ( 1 ? t("mergeCommitRefused") : undefined} testId="lane-changes-menu-cherry-pick" onSelect={pick(() => openPick("cherry-pick", [menuItem.sha]))} > {t("lanes:changes.cherryPick", { branch: target_branch })} )} )} {dialog && ( setDialog(null)} onDone={(r) => afterPick(dialog.op, r)} /> )} {browse && ( setBrowse(null)} /> )} {compare && ( setCompare(null)} onOpenCommit={(sha) => { setCompare(null); onViewCommit(sha); }} /> )} {newRef && ( setNewRef(null)} onDone={() => { setNewRef(null); onChanged(); }} /> )}
); }