/** Git menu dialogs: Pull…, Merge…, Rebase…, Manage Remotes… and Worktrees. */ import { useEffect, useMemo, useState } from "react"; import clsx from "clsx"; import type { Branch, MergeOptions, Remote, Worktree } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands } from "@/platform/ipc"; import { notifications } from "@/workbench/contributions"; import { Checkbox, Dialog, Icon, IconButton, Input, Select, T, useSanitizedField, type SelectOption } from "@/ui"; import { sortLocal, sortRemote } from "./branches"; import { conflictActions, openConflictsInstead, validateBranchName } from "./branchOps"; import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName"; import { confirmDialog, openDialog } from "./dialogs"; import { errorMessage, runOp, runRemote, saveAll } from "./ops"; import { currentRepo, loadBranches, refreshNow, repoName, selectRepo, useVcs } from "./store"; import { isCurrentWorktree } from "./worktrees"; import styles from "./common.module.css"; import { tx } from "@/platform/i18n"; // --- shared ----------------------------------------------------------------- export type MergeFlag = "noFf" | "ffOnly" | "squash" | "noCommit"; /** Flags git refuses together with `flag`. */ const EXCLUSIVE: Record = { noFf: ["ffOnly", "squash"], ffOnly: ["noFf", "squash", "noCommit"], squash: ["noFf", "ffOnly"], noCommit: ["ffOnly"], }; /** Merge flags that cannot be turned on next to the `on` ones (and with `--rebase`). */ export function disabledMergeFlags(on: ReadonlySet, rebase = false): Set { if (rebase) return new Set(["noFf", "ffOnly", "squash", "noCommit"]); const out = new Set(); for (const f of on) for (const x of EXCLUSIVE[f]) out.add(x); return out; } export function mergeOptionsOf(on: ReadonlySet, message: string | null = null): MergeOptions { return { noFf: on.has("noFf"), ffOnly: on.has("ffOnly"), squash: on.has("squash"), noCommit: on.has("noCommit"), message: message?.trim() || null, }; } const FLAG_LABELS: Array<[MergeFlag, string]> = [ ["noFf", "--no-ff"], ["ffOnly", "--ff-only"], ["squash", "--squash"], ["noCommit", "--no-commit"], ]; function MergeFlags({ on, setOn, rebase = false, }: { on: Set; setOn: (next: Set) => void; rebase?: boolean; }) { const disabled = disabledMergeFlags(on, rebase); return (
{FLAG_LABELS.map(([flag, label]) => ( { const next = new Set(on); if (v) next.add(flag); else next.delete(flag); setOn(next); }} /> ))}
); } function useBranches(): Branch[] { const [repo] = useState(currentRepo); const [branches, setBranches] = useState([]); useEffect(() => { let live = true; void call(commands.vcsBranches(repo)) .then((b) => live && setBranches(b)) .catch(() => undefined); return () => { live = false; }; }, []); return branches; } const branchOptions = (list: readonly Branch[]): SelectOption[] => list.filter((b) => !b.name.endsWith("/HEAD")).map((b) => ({ value: b.name, label: b.name, icon: "branch" })); /** Remote branches of `remote` without the remote prefix (`origin/main` → `main`). */ export function remoteBranchesOf(branches: readonly Branch[], remote: string): string[] { const prefix = `${remote}/`; return sortRemote(branches) .filter((b) => b.name.startsWith(prefix) && b.name !== `${prefix}HEAD`) .map((b) => b.name.slice(prefix.length)); } // --- Pull ------------------------------------------------------------------- export function showPullDialog() { openDialog((close) => ); } function PullDialog({ onClose }: { onClose: () => void }) { const [repo] = useState(currentRepo); const [info] = useState(() => useVcs.getState().branch); const branches = useBranches(); const [remotes, setRemotes] = useState(null); const [remote, setRemote] = useState(null); const [chosenBranch, setBranch] = useState(null); const [rebase, setRebase] = useState(false); const [flags, setFlags] = useState>(() => new Set()); useEffect(() => { let alive = true; call(commands.vcsRemotes(repo)) .then((list) => { if (!alive) return; setRemotes(list); const upstreamRemote = info?.upstream?.split("/")[0]; setRemote(list.find((r) => r.name === upstreamRemote)?.name ?? list[0]?.name ?? null); }) .catch(() => alive && setRemotes([])); return () => { alive = false; }; }, [info?.upstream]); const remoteBranches = useMemo(() => (remote ? remoteBranchesOf(branches, remote) : []), [branches, remote]); // Default: the upstream's branch, else the same name as the current branch. const upstream = remote && info?.upstream?.startsWith(`${remote}/`) ? info.upstream.slice(remote.length + 1) : null; const preferred = upstream ?? info?.head ?? null; const branch = chosenBranch && remoteBranches.includes(chosenBranch) ? chosenBranch : preferred && remoteBranches.includes(preferred) ? preferred : (remoteBranches[0] ?? null); const run = async () => { if (!remote) return; onClose(); await saveAll(); await runRemote( { op: "pullFrom", remote, branches: branch ? [branch] : [], rebase, options: mergeOptionsOf(rebase ? new Set() : flags), }, `${info?.head ?? "HEAD"} ← ${remote}${branch ? `/${branch}` : ""}`, [repo], ); }; return ( void run() }, { label: "Cancel", role: "cancel" }, ]} >
{remotes && !remotes.length ? (
No remotes are configured. Add one with Git › Manage Remotes….
) : (
({ value: b, label: b, icon: "branch" }))} onChange={setBranch} />
)}
Options:
Uncommitted changes are stashed before the pull and restored afterwards.
); } // --- Merge ------------------------------------------------------------------ export function showMergeDialog() { openDialog((close) => ); } function MergeDialog({ onClose }: { onClose: () => void }) { const [repo] = useState(currentRepo); const [head] = useState(() => useVcs.getState().branch?.head ?? null); const branches = useBranches(); const [branch, setBranch] = useState(null); const [flags, setFlags] = useState>(() => new Set()); const [withMessage, setWithMessage] = useState(false); const [message, setMessage] = useState(""); const options = useMemo( () => [...branchOptions(sortLocal(branches).filter((b) => !b.current)), ...branchOptions(sortRemote(branches))], [branches], ); const run = async () => { if (!branch) return; onClose(); await saveAll(); const target = head ?? "HEAD"; await runOp( tx`Merge ${branch} into ${target}`, () => call(commands.vcsMergeWith(repo, [branch], mergeOptionsOf(flags, withMessage ? message : null))), { success: tx`Merged ${branch} into ${target}`, errorActions: conflictActions, onError: openConflictsInstead }, ); await refreshNow(); }; return ( void run() }, { label: "Cancel", role: "cancel" }, ]} >
Branch to merge:
setMessage(e.target.value)} /> )}
); } // --- Rebase ----------------------------------------------------------------- export function showRebaseDialog() { openDialog((close) => ); } function RebaseDialog({ onClose }: { onClose: () => void }) { const [repo] = useState(currentRepo); const [head] = useState(() => useVcs.getState().branch?.head ?? null); const branches = useBranches(); const [upstream, setUpstream] = useState(null); const [useOnto, setUseOnto] = useState(false); const [onto, setOnto] = useState(null); const [rebaseMerges, setRebaseMerges] = useState(false); const [keepEmpty, setKeepEmpty] = useState(false); const options = useMemo( () => [...branchOptions(sortLocal(branches).filter((b) => !b.current)), ...branchOptions(sortRemote(branches))], [branches], ); const ready = !!upstream && (!useOnto || !!onto); const run = async () => { if (!upstream) return; onClose(); await saveAll(); const target = useOnto && onto ? onto : upstream; const current = head ?? "HEAD"; await runOp( tx`Rebase ${current} onto ${target}`, () => call( commands.vcsRebaseWith(repo, upstream, { onto: useOnto ? onto : null, rebaseMerges, keepEmpty, }), ), { success: tx`Rebased ${current} onto ${target}`, errorActions: conflictActions, onError: openConflictsInstead }, ); await refreshNow(); }; return ( void run() }, { label: "Cancel", role: "cancel" }, ]} >
{useOnto ? tx`Rebase commits of ${head ?? "HEAD"} not on:` : `Rebase ${head ?? "HEAD"} onto:`}
)}
Uncommitted changes are stashed before the rebase and restored afterwards.
); } // --- Manage Remotes --------------------------------------------------------- /** Error for a remote name, or null (`existing`: the other remotes' names). */ export function validateRemoteName(name: string, existing: readonly string[]): string | null { const n = name.trim(); if (!n) return "Enter a remote name"; if (/\s/.test(n) || n.startsWith("-") || /[~^:?*[\\]/.test(n) || n.includes("..")) return "Invalid remote name"; if (existing.includes(n)) return `Remote '${n}' already exists`; return null; } export function showManageRemotes() { openDialog((close) => ); } function RemotesDialog({ onClose }: { onClose: () => void }) { const [repo] = useState(currentRepo); const [remotes, setRemotes] = useState([]); const [selected, setSelected] = useState(null); const [error, setError] = useState(null); const reload = () => call(commands.vcsRemotes(repo)) .then((list) => { setRemotes(list); setSelected((s) => (s && list.some((r) => r.name === s) ? s : (list[0]?.name ?? null))); }) .catch((e) => setError(errorMessage(e))); useEffect(() => { void reload(); }, []); const apply = async (edit: Parameters[1]) => { try { await call(commands.vcsEditRemote(repo, edit)); setError(null); } catch (e) { setError(errorMessage(e)); } await reload(); void loadBranches().catch(() => undefined); }; const edit = (remote?: Remote) => openDialog((close) => ( r.name).filter((n) => n !== remote?.name)} onDone={(r) => { close(); if (!r) return; void apply( remote ? { op: "edit", name: remote.name, new_name: r.name, url: r.url } : { op: "add", name: r.name, url: r.url }, ); }} /> )); const remove = async () => { if (!selected) return; const ok = await confirmDialog({ title: "Remove Remote", message: tx`Remove remote '${selected}'? Its remote-tracking branches are deleted.`, okLabel: "Remove", danger: true, }); if (ok) await apply({ op: "remove", name: selected }); }; const current = remotes.find((r) => r.name === selected); return (
edit()} /> void remove()} /> edit(current)} />
{!remotes.length && (
No remotes
)} {remotes.map((r) => ( ))}
{error &&
{error}
}
); } function RemoteEditDialog({ remote, existing, onDone, }: { remote?: Remote; existing: readonly string[]; onDone: (r: { name: string; url: string } | null) => void; }) { const [name, setName] = useState(remote?.name ?? (existing.includes("origin") ? "" : "origin")); const [url, setUrl] = useState(remote?.url ?? ""); const error = validateRemoteName(name, existing) ?? (url.trim() ? null : "Enter the remote URL"); const submit = () => { if (!error) onDone({ name: name.trim(), url: url.trim() }); }; return ( onDone(null)} width={460} buttons={[ { label: "OK", role: "default", disabled: !!error, onClick: submit }, { label: "Cancel", role: "cancel" }, ]} >
{ e.preventDefault(); submit(); }} > setName(e.target.value)} /> setUrl(e.target.value)} /> {error && (name || url) &&
{error}
}
); } // --- Worktrees -------------------------------------------------------------- /** Default folder of a new worktree: next to the repository, `-`. */ export function defaultWorktreePath(repoRoot: string, branch: string): string { const sep = repoRoot.includes("\\") && !repoRoot.includes("/") ? "\\" : "/"; const trimmed = repoRoot.replace(/[\\/]+$/, ""); const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); const parent = cut > 0 ? trimmed.slice(0, cut) : trimmed; const name = trimmed.slice(cut + 1); const suffix = branch.replace(/[\\/]/g, "-"); return `${parent}${sep}${name}-${suffix || "worktree"}`; } export function showWorktrees() { openDialog((close) => ); } export function showNewWorktree(onCreated?: () => void) { openDialog((close) => ( { close(); if (created) onCreated?.(); }} /> )); } function WorktreesDialog({ onClose }: { onClose: () => void }) { return ( ); } /** * The worktrees of the current repository with New / Remove / Open: the Worktrees * dialog's content and the Git tool window's Worktrees tab. `listClassName` replaces * the dialog's bordered list box. */ export function WorktreesPanel({ className, listClassName }: { className?: string; listClassName?: string }) { const repo = useVcs((s) => s.repo); const repos = useVcs((s) => s.roots); const [list, setList] = useState([]); const [selected, setSelected] = useState(null); const [error, setError] = useState(null); const reload = () => call(commands.vcsWorktrees(repo)) .then((l) => { setList(l); setError(null); }) .catch((e) => setError(errorMessage(e))); useEffect(() => { void reload(); }, [repo]); const current = list.find((w) => w.path === selected); const remove = async () => { if (!current || current.main) return; const ok = await confirmDialog({ title: "Remove Worktree", message: tx`Remove the worktree at ${current.path}? Its folder is deleted.`, okLabel: "Remove", danger: true, }); if (!ok) return; try { await call(commands.vcsRemoveWorktree(repo, current.path, false)); } catch (e) { const force = await confirmDialog({ title: "Remove Worktree", message: tx`${errorMessage(e)}\n\nRemove it anyway, discarding its changes?`, okLabel: "Force Remove", danger: true, }); if (force) await call(commands.vcsRemoveWorktree(repo, current.path, true)).catch((x) => setError(errorMessage(x))); } setSelected(null); await reload(); }; return (
{repos.length > 1 && ( {createBranch && ( setNewBranch(cleanBranch(e))} /> )}
Folder:
setPath(e.target.value)} /> {(error ?? (source && invalid)) &&
{error ?? invalid}
}
); }