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

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>
);
}