2cc492bb88
GET /git/refs lists local branches (upstream, ahead/behind, last commit, merged, held by a worktree, protected), remote branches grouped by remote and tags (target, annotated message/tagger/date). New writes in server/lib/git-refs.js, dispatched from git-ops.js: branch-rename, branch-upstream (set/unset), tag-create (light/annotated), tag-delete (confirm), tag-push (one tag to origin, no --tags, no force), and branch-create with a looked-up from + switch on a clean tree. Every ref is looked up, never passed through; repo-wide writes are repo-root only (ESUBDIR) and refused during any operation (EMERGING). Client: git/BranchManager + TagManager dialog opened from the git bar's tags icon, en+vi (gitRefs namespace), keyboard navigable. # Conflicts: # CLAUDE.md # client/src/components/lanes/LaneGitBar.tsx # docs/API.md # server/__tests__/lanes-api.test.js # server/routes/lanes.js
642 lines
21 KiB
TypeScript
642 lines
21 KiB
TypeScript
/**
|
|
* @file The git bar's Branches panel (JetBrains' branch popup, GitLens'
|
|
* Branches view): a dialog over the workspace listing every local branch
|
|
* (current marked, upstream with ahead/behind, last commit, why a delete would
|
|
* be refused) and the fetched remote branches grouped by remote, filtered by a
|
|
* search box and walked with the arrow keys; picking a row opens its actions —
|
|
* new branch from it (optionally switching to it on a clean tree), rename,
|
|
* set/unset upstream, merge into the current branch, delete. Its Tags tab is
|
|
* `TagManager`. Everything it may do is decided by the server
|
|
* (`GET …/git/refs`, `POST …/git/:action`, server/lib/git-refs.js); a disabled
|
|
* button here is a courtesy, and git's own text is shown when it refuses.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ArrowDown,
|
|
ArrowUp,
|
|
Check,
|
|
GitBranch,
|
|
GitMerge,
|
|
Link2,
|
|
Link2Off,
|
|
Pencil,
|
|
Plus,
|
|
Search,
|
|
Tag,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import {
|
|
api,
|
|
gitTarget,
|
|
type GitLocalBranch,
|
|
type GitRefs,
|
|
type GitRemoteBranch,
|
|
type GitSource,
|
|
} from "../../../lib/api";
|
|
import { useDialog } from "../../../hooks/useDialog";
|
|
import { confirmDialog } from "../../../lib/confirm";
|
|
import { toast } from "../../../lib/toast";
|
|
import { isSubmitEnter } from "../../../lib/keys";
|
|
import { timeAgo } from "../../../lib/format";
|
|
import { Checkbox } from "../../Checkbox";
|
|
import { TagManager } from "./TagManager";
|
|
import { onListKeys, smallBtn, smallInput, type RunRefAction } from "./refUi";
|
|
|
|
type Mode = "new" | "rename" | "upstream" | null;
|
|
|
|
export function BranchManager({
|
|
laneId,
|
|
session,
|
|
source,
|
|
dirty,
|
|
merging,
|
|
onClose,
|
|
onChanged,
|
|
}: {
|
|
laneId: number;
|
|
session?: string;
|
|
source?: GitSource;
|
|
/** Uncommitted files: a "switch to it" is refused on a dirty tree. */
|
|
dirty: number;
|
|
/** A merge, cherry-pick or revert is open: every write here is refused. */
|
|
merging: boolean;
|
|
onClose: () => void;
|
|
/** Called after every write, so the git bar and the Changes panel re-read. */
|
|
onChanged: () => void;
|
|
}) {
|
|
const { t } = useTranslation(["gitRefs"]);
|
|
const titleId = useId();
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
useDialog(true, panelRef, onClose, searchRef);
|
|
|
|
const target = gitTarget(laneId, session, source);
|
|
const [refs, setRefs] = useState<GitRefs | null>(null);
|
|
const [tab, setTab] = useState<"branches" | "tags">("branches");
|
|
const [query, setQuery] = useState("");
|
|
const [selected, setSelected] = useState<string | null>(null);
|
|
const [mode, setMode] = useState<Mode>(null);
|
|
const [text, setText] = useState("");
|
|
const [switchTo, setSwitchTo] = useState(false);
|
|
const [upstream, setUpstream] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const inFlight = useRef(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setRefs(await api.lanes.gitRefs(target));
|
|
} catch {
|
|
setRefs({
|
|
available: false,
|
|
current: null,
|
|
head: null,
|
|
local: [],
|
|
remotes: [],
|
|
tags: [],
|
|
truncated: false,
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [laneId, session, source]);
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const run: RunRefAction = async (action, body, done) => {
|
|
if (inFlight.current) return false;
|
|
inFlight.current = true;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await api.lanes.gitRefAction(target, action, body);
|
|
if (done) toast.success(done);
|
|
setMode(null);
|
|
setText("");
|
|
await load();
|
|
onChanged();
|
|
return true;
|
|
} catch (err) {
|
|
const code = (err as { code?: string })?.code;
|
|
setError(
|
|
code === "EGITAUTH"
|
|
? t("errLogin")
|
|
: code === "ESUBDIR"
|
|
? t("errSubdir")
|
|
: err instanceof Error
|
|
? err.message
|
|
: String(err)
|
|
);
|
|
return false;
|
|
} finally {
|
|
inFlight.current = false;
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const q = query.trim().toLowerCase();
|
|
const match = (s: string) => !q || s.toLowerCase().includes(q);
|
|
const local = useMemo(
|
|
() => (refs?.local || []).filter((b) => match(b.name)),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[refs, q]
|
|
);
|
|
const remoteGroups = useMemo(
|
|
() =>
|
|
(refs?.remotes || [])
|
|
.map((g) => ({ ...g, branches: g.branches.filter((b) => match(b.name)) }))
|
|
.filter((g) => g.branches.length),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[refs, q]
|
|
);
|
|
const allRemote = useMemo(
|
|
() => (refs?.remotes || []).flatMap((g) => g.branches.map((b) => b.name)),
|
|
[refs]
|
|
);
|
|
const current = refs?.current ?? null;
|
|
|
|
const pick = (key: string) => {
|
|
setSelected((s) => (s === key ? null : key));
|
|
setMode(null);
|
|
setText("");
|
|
setError(null);
|
|
};
|
|
const open = (m: Mode, initial = "") => {
|
|
setMode((v) => (v === m ? null : m));
|
|
setText(initial);
|
|
setSwitchTo(false);
|
|
setError(null);
|
|
};
|
|
|
|
const merge = async (branch: string) => {
|
|
const ok = await confirmDialog({
|
|
title: t("merge"),
|
|
message: t("mergeConfirm", { branch, current: current ?? "HEAD" }),
|
|
confirmLabel: t("merge"),
|
|
destructive: false,
|
|
});
|
|
if (ok) await run("merge", { branch }, t("done.merge", { branch }));
|
|
};
|
|
const remove = async (b: GitLocalBranch) => {
|
|
const ok = await confirmDialog({
|
|
title: t("delete"),
|
|
message: b.merged
|
|
? t("deleteConfirm", { branch: b.name })
|
|
: t("deleteUnmerged", { branch: b.name }),
|
|
confirmLabel: t("delete"),
|
|
destructive: true,
|
|
});
|
|
if (ok) {
|
|
if (await run("branch-delete", { branch: b.name }, t("done.delete", { branch: b.name })))
|
|
setSelected(null);
|
|
}
|
|
};
|
|
|
|
/** Why `git branch -d` would refuse this branch, or null. */
|
|
const deleteBlock = (b: GitLocalBranch) =>
|
|
b.current
|
|
? t("why.current")
|
|
: b.protected
|
|
? t("why.protected")
|
|
: b.worktree
|
|
? t("why.worktree", { path: b.worktree })
|
|
: null;
|
|
|
|
const newBranchForm = (from: string) => (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<input
|
|
autoFocus
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (isSubmitEnter(e) && text.trim())
|
|
void run(
|
|
"branch-create",
|
|
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
|
|
t("done.create", { branch: text.trim() })
|
|
);
|
|
}}
|
|
placeholder={t("newBranchName")}
|
|
aria-label={t("newBranchName")}
|
|
className={smallInput}
|
|
/>
|
|
<Checkbox
|
|
checked={switchTo}
|
|
onChange={setSwitchTo}
|
|
disabled={dirty > 0 || merging}
|
|
label={t("switchToIt")}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className={smallBtn}
|
|
disabled={busy || !text.trim()}
|
|
onClick={() =>
|
|
void run(
|
|
"branch-create",
|
|
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
|
|
t("done.create", { branch: text.trim() })
|
|
)
|
|
}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
{t("create")}
|
|
</button>
|
|
{dirty > 0 && <span className="text-[10px] text-fg-muted">{t("switchDirty")}</span>}
|
|
</div>
|
|
);
|
|
|
|
const localActions = (b: GitLocalBranch) => {
|
|
const block = deleteBlock(b);
|
|
return (
|
|
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
|
|
<div className="flex flex-wrap gap-1">
|
|
<button type="button" className={smallBtn} disabled={busy} onClick={() => open("new")}>
|
|
<Plus className="h-3 w-3" />
|
|
{t("newFrom")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={smallBtn}
|
|
disabled={busy || b.protected || merging}
|
|
title={b.protected ? t("why.protected") : t("renameHint")}
|
|
onClick={() => open("rename", b.name)}
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
{t("rename")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={smallBtn}
|
|
disabled={busy || merging || !allRemote.length}
|
|
title={allRemote.length ? t("upstreamHint") : t("noRemoteBranches")}
|
|
onClick={() => {
|
|
open("upstream");
|
|
setUpstream(b.upstream ?? "");
|
|
}}
|
|
>
|
|
<Link2 className="h-3 w-3" />
|
|
{t("setUpstream")}
|
|
</button>
|
|
{b.upstream && (
|
|
<button
|
|
type="button"
|
|
className={smallBtn}
|
|
disabled={busy || merging}
|
|
onClick={() =>
|
|
void run("branch-upstream", { branch: b.name }, t("done.unsetUpstream"))
|
|
}
|
|
>
|
|
<Link2Off className="h-3 w-3" />
|
|
{t("unsetUpstream")}
|
|
</button>
|
|
)}
|
|
{!b.current && (
|
|
<button
|
|
type="button"
|
|
className={smallBtn}
|
|
disabled={busy || merging || dirty > 0 || !current}
|
|
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
|
|
onClick={() => void merge(b.name)}
|
|
>
|
|
<GitMerge className="h-3 w-3" />
|
|
{t("mergeInto", { current: current ?? "HEAD" })}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={`${smallBtn} hover:text-status-danger`}
|
|
disabled={busy || !!block}
|
|
title={block ?? (b.merged ? t("deleteHint") : t("why.unmerged"))}
|
|
onClick={() => void remove(b)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
{t("delete")}
|
|
</button>
|
|
</div>
|
|
{block && <p className="text-[10px] text-fg-muted [overflow-wrap:anywhere]">{block}</p>}
|
|
{!block && !b.merged && (
|
|
<p className="text-[10px] text-status-warning">{t("why.unmerged")}</p>
|
|
)}
|
|
{mode === "new" && newBranchForm(b.name)}
|
|
{mode === "rename" && (
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
autoFocus
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (isSubmitEnter(e) && text.trim() && text.trim() !== b.name)
|
|
void run(
|
|
"branch-rename",
|
|
{ branch: b.name, name: text.trim() },
|
|
t("done.rename", { branch: text.trim() })
|
|
);
|
|
}}
|
|
aria-label={t("renameTo")}
|
|
placeholder={t("renameTo")}
|
|
className={smallInput}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className={smallBtn}
|
|
disabled={busy || !text.trim() || text.trim() === b.name}
|
|
onClick={() =>
|
|
void run(
|
|
"branch-rename",
|
|
{ branch: b.name, name: text.trim() },
|
|
t("done.rename", { branch: text.trim() })
|
|
)
|
|
}
|
|
>
|
|
<Check className="h-3 w-3" />
|
|
{t("rename")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{mode === "upstream" && (
|
|
<div className="flex items-center gap-1.5">
|
|
<select
|
|
value={upstream}
|
|
onChange={(e) => setUpstream(e.target.value)}
|
|
aria-label={t("setUpstream")}
|
|
className="select min-w-0 flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
|
>
|
|
<option value="">{t("pickUpstream")}</option>
|
|
{allRemote.map((r) => (
|
|
<option key={r} value={r}>
|
|
{r}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className={smallBtn}
|
|
disabled={busy || !upstream || upstream === b.upstream}
|
|
onClick={() =>
|
|
void run(
|
|
"branch-upstream",
|
|
{ branch: b.name, upstream },
|
|
t("done.setUpstream", { upstream })
|
|
)
|
|
}
|
|
>
|
|
<Check className="h-3 w-3" />
|
|
{t("apply")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const remoteActions = (r: GitRemoteBranch) => (
|
|
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
|
|
<div className="flex flex-wrap gap-1">
|
|
<button
|
|
type="button"
|
|
className={smallBtn}
|
|
disabled={busy}
|
|
onClick={() => open("new", r.branch)}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
{t("newFrom")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={smallBtn}
|
|
disabled={busy || merging || dirty > 0 || !current}
|
|
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
|
|
onClick={() => void merge(r.name)}
|
|
>
|
|
<GitMerge className="h-3 w-3" />
|
|
{t("mergeInto", { current: current ?? "HEAD" })}
|
|
</button>
|
|
</div>
|
|
{mode === "new" && newBranchForm(r.name)}
|
|
</div>
|
|
);
|
|
|
|
const row = (
|
|
key: string,
|
|
name: string,
|
|
sub: React.ReactNode,
|
|
badges: React.ReactNode,
|
|
actions: () => React.ReactNode
|
|
) => (
|
|
<li key={key} className={selected === key ? "rounded bg-surface-2" : undefined}>
|
|
<button
|
|
type="button"
|
|
data-ref-row
|
|
aria-expanded={selected === key}
|
|
onClick={() => pick(key)}
|
|
className="flex w-full min-w-0 items-start gap-2 rounded px-2 py-1 text-left hover:bg-surface-3 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent"
|
|
>
|
|
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
<span title={name} className="truncate font-mono text-xs text-fg-primary">
|
|
{name}
|
|
</span>
|
|
{badges}
|
|
</span>
|
|
<span className="block truncate text-[10px] text-fg-muted">{sub}</span>
|
|
</span>
|
|
</button>
|
|
{selected === key && actions()}
|
|
</li>
|
|
);
|
|
|
|
const badge = (label: string, tone = "text-fg-muted border-border-light") => (
|
|
<span className={`shrink-0 rounded border px-1 text-[9px] uppercase ${tone}`}>{label}</span>
|
|
);
|
|
|
|
const localRow = (b: GitLocalBranch) =>
|
|
row(
|
|
`l:${b.name}`,
|
|
b.name,
|
|
<>
|
|
{b.subject}
|
|
{b.date ? ` · ${timeAgo(b.date)}` : ""}
|
|
</>,
|
|
<>
|
|
{b.current && badge(t("current"), "text-accent border-accent/40")}
|
|
{b.protected && badge(t("protected"))}
|
|
{b.worktree && (
|
|
<span title={b.worktree} className="shrink-0">
|
|
{badge(t("worktree"))}
|
|
</span>
|
|
)}
|
|
{b.upstream && (
|
|
<span
|
|
title={b.upstream}
|
|
className="flex shrink-0 items-center gap-0.5 font-mono text-[10px] text-fg-muted"
|
|
>
|
|
{b.gone ? (
|
|
<span className="text-status-warning">{t("gone")}</span>
|
|
) : (
|
|
<>
|
|
{(b.ahead ?? 0) > 0 && (
|
|
<span className="flex items-center text-status-warning">
|
|
<ArrowUp className="h-3 w-3" aria-label={t("ahead")} />
|
|
{b.ahead}
|
|
</span>
|
|
)}
|
|
{(b.behind ?? 0) > 0 && (
|
|
<span className="flex items-center text-accent">
|
|
<ArrowDown className="h-3 w-3" aria-label={t("behind")} />
|
|
{b.behind}
|
|
</span>
|
|
)}
|
|
{!b.ahead && !b.behind && <Check className="h-3 w-3" aria-label={t("inSync")} />}
|
|
</>
|
|
)}
|
|
</span>
|
|
)}
|
|
</>,
|
|
() => localActions(b)
|
|
);
|
|
|
|
const remoteRow = (r: GitRemoteBranch) =>
|
|
row(
|
|
`r:${r.name}`,
|
|
r.name,
|
|
<>
|
|
{r.subject}
|
|
{r.date ? ` · ${timeAgo(r.date)}` : ""}
|
|
</>,
|
|
null,
|
|
() => remoteActions(r)
|
|
);
|
|
|
|
const tabBtn = (id: "branches" | "tags", label: string, icon: React.ReactNode) => (
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === id}
|
|
onClick={() => {
|
|
setTab(id);
|
|
setSelected(null);
|
|
setMode(null);
|
|
setError(null);
|
|
}}
|
|
className={`flex items-center gap-1 border-b-2 px-2 py-1 text-xs ${
|
|
tab === id
|
|
? "border-accent text-fg-primary"
|
|
: "border-transparent text-fg-muted hover:text-fg-primary"
|
|
}`}
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[55] flex items-start justify-center bg-black/60 p-4 sm:pt-16"
|
|
onClick={onClose}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={titleId}
|
|
data-testid="branch-manager"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="flex max-h-full w-full max-w-xl min-w-0 flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-border-light px-3 pt-2">
|
|
<h3 id={titleId} className="sr-only">
|
|
{t("title")}
|
|
</h3>
|
|
<div role="tablist" className="flex min-w-0 flex-1 gap-1">
|
|
{tabBtn("branches", t("branches"), <GitBranch className="h-3.5 w-3.5" />)}
|
|
{tabBtn("tags", t("tags"), <Tag className="h-3.5 w-3.5" />)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn-icon -mt-1"
|
|
aria-label={t("close")}
|
|
onClick={onClose}
|
|
>
|
|
<X className="h-4 w-4" aria-hidden />
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 px-3 py-2">
|
|
<Search className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
|
<input
|
|
ref={searchRef}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
panelRef.current?.querySelector<HTMLElement>("[data-ref-row]")?.focus();
|
|
}
|
|
}}
|
|
placeholder={tab === "tags" ? t("searchTags") : t("searchBranches")}
|
|
aria-label={tab === "tags" ? t("searchTags") : t("searchBranches")}
|
|
className={`${smallInput} !text-xs`}
|
|
/>
|
|
</div>
|
|
{merging && <p className="px-3 pb-1 text-[11px] text-status-warning">{t("merging")}</p>}
|
|
{error && (
|
|
<pre
|
|
role="alert"
|
|
data-testid="branch-manager-error"
|
|
className="mx-3 mb-2 max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger [overflow-wrap:anywhere]"
|
|
>
|
|
{error}
|
|
</pre>
|
|
)}
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3" onKeyDown={onListKeys}>
|
|
{!refs ? (
|
|
<p className="px-2 py-4 text-xs text-fg-muted">{t("loading")}</p>
|
|
) : !refs.available ? (
|
|
<p className="px-2 py-4 text-xs text-fg-muted">{t("unavailable")}</p>
|
|
) : tab === "tags" ? (
|
|
<TagManager
|
|
tags={(refs.tags || []).filter((x) => match(x.name))}
|
|
busy={busy}
|
|
merging={merging}
|
|
dirty={dirty}
|
|
run={run}
|
|
/>
|
|
) : (
|
|
<>
|
|
<h4 className="px-2 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
|
|
{t("local")}
|
|
</h4>
|
|
{local.length ? (
|
|
<ul>{local.map(localRow)}</ul>
|
|
) : (
|
|
<p className="px-2 text-xs text-fg-muted">{t("noMatch")}</p>
|
|
)}
|
|
{remoteGroups.map((g) => (
|
|
<section key={g.remote}>
|
|
<h4
|
|
title={g.remote}
|
|
className="truncate px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-fg-muted"
|
|
>
|
|
{t("remote", { remote: g.remote })}
|
|
</h4>
|
|
<ul>{g.branches.map(remoteRow)}</ul>
|
|
</section>
|
|
))}
|
|
{refs.truncated && (
|
|
<p className="px-2 pt-2 text-[10px] text-fg-muted">{t("truncated")}</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default BranchManager;
|