mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
Ref-name inputs only validated after the fact, so a space typed or pasted into a branch name showed "Branch name contains invalid characters" and disabled Create. Branch-name fields (New Branch, from a revision and in several repositories, Rename Branch, New Worktree, GitHub New Remote Branch) now clean the name while typing: forbidden characters are dropped and whitespace becomes '-', without stacking separators, keeping the caret in place. Shared via src/platform/gitRefName.ts and the useSanitizedField hook in src/ui. Closes #1553 Co-authored-by: jaw52 <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
488 lines
18 KiB
TypeScript
488 lines
18 KiB
TypeScript
/** Branch operations (checkout, create, merge, rebase, update, delete) with recents and favorites. */
|
|
import { create } from "zustand";
|
|
import type { Branch, CheckoutOptions } from "@/bindings";
|
|
import { executeAction } from "@/platform/actions";
|
|
import { call, commands } from "@/platform/ipc";
|
|
import { notifications } from "@/workbench/contributions";
|
|
import { ask } from "@/workbench/messages";
|
|
import { isFavoriteBranch, pushRecent, shortRemoteName } from "./branches";
|
|
import { showCommitRange } from "./CompareBranches";
|
|
import { confirmDialog, promptText } from "./dialogs";
|
|
import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName";
|
|
import { runOp, runRemote, saveAll } from "./ops";
|
|
import { conflictedFiles, currentRepo, refreshNow, repoName, useVcs } from "./store";
|
|
import { tx } from "@/platform/i18n";
|
|
|
|
const recentKey = () => `openidea.vcs.recentBranches:${useVcs.getState().root ?? ""}`;
|
|
|
|
export function recentBranches(): string[] {
|
|
try {
|
|
const raw = localStorage.getItem(recentKey());
|
|
const v: unknown = raw ? JSON.parse(raw) : [];
|
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function rememberBranch(name: string | null | undefined) {
|
|
if (!name) return;
|
|
try {
|
|
localStorage.setItem(recentKey(), JSON.stringify(pushRecent(recentBranches(), name)));
|
|
} catch {
|
|
// Storage unavailable: recents are a convenience only.
|
|
}
|
|
}
|
|
|
|
/** Valid-enough git ref name check (mirrors `git check-ref-format` basics). */
|
|
export function validateBranchName(name: string): string | null {
|
|
const n = name.trim();
|
|
if (!n) return "Branch name must not be empty";
|
|
if (/[\s~^:?*[\\\p{Cc}]/u.test(n)) return "Branch name contains invalid characters";
|
|
if (n.startsWith("-") || n.startsWith("/") || n.endsWith("/") || n.endsWith(".") || n.endsWith(".lock"))
|
|
return "Invalid branch name";
|
|
if (n.includes("..") || n.includes("//") || n.includes("@{") || n === "@") return "Invalid branch name";
|
|
const exists = useVcs.getState().branches.some((b) => !b.remote && b.name === n);
|
|
if (exists) return `Branch '${n}' already exists`;
|
|
return null;
|
|
}
|
|
|
|
/** IDEA's Smart Checkout dialog; the chosen mode, or null for Don't Checkout. */
|
|
async function askSmartCheckout(files: readonly string[]): Promise<CheckoutOptions["mode"] | null> {
|
|
const list = files.slice(0, 20).join("\n") + (files.length > 20 ? "\n…" : "");
|
|
const r = await ask({
|
|
title: "Git Checkout Problem",
|
|
body: tx`Your local changes to the following files would be overwritten by checkout:\n${list}\n\nSmart Checkout can stash the changes, checkout and unstash them after that.`,
|
|
icon: "warning",
|
|
width: 520,
|
|
buttons: [
|
|
{ id: "smart", label: "Smart Checkout", primary: true },
|
|
{ id: "force", label: "Force Checkout" },
|
|
{ id: "cancel", label: "Don't Checkout" },
|
|
],
|
|
});
|
|
return r.button === "smart" || r.button === "force" ? r.button : null;
|
|
}
|
|
|
|
/**
|
|
* Checks out `rev` as IDEA does: local changes in the way open the Smart Checkout
|
|
* dialog, and a remote branch whose local namesake has commits of its own asks whether
|
|
* to rebase that branch onto the remote or drop its commits (one that is only behind is
|
|
* fast-forwarded). Resolves true once switched.
|
|
*/
|
|
async function performCheckout(
|
|
rev: string,
|
|
success: () => string,
|
|
repo: string | null = currentRepo(),
|
|
quiet = false,
|
|
): Promise<boolean> {
|
|
await saveAll();
|
|
let options: CheckoutOptions = { mode: "normal", diverged: null };
|
|
for (;;) {
|
|
const opts = options;
|
|
const outcome = await runOp(tx`Checkout ${rev}`, () => call(commands.vcsCheckout(repo, rev, false, opts)));
|
|
if (!outcome) return false;
|
|
// `success` may name the branch now checked out.
|
|
if (outcome.kind === "done" || outcome.kind === "conflicts") await refreshNow();
|
|
switch (outcome.kind) {
|
|
case "done":
|
|
if (!quiet) notifications().notify({ group: "vcs", level: "info", title: success() });
|
|
return true;
|
|
case "conflicts":
|
|
notifications().notify({
|
|
group: "vcs",
|
|
level: "warning",
|
|
title: `${success()}, with conflicts`,
|
|
body: outcome.files.join("\n"),
|
|
actions: [{ label: "Resolve Conflicts…", run: () => void executeAction("vcs.resolveConflicts") }],
|
|
});
|
|
await openConflictsInstead();
|
|
return true;
|
|
case "localChanges": {
|
|
const mode = await askSmartCheckout(outcome.files);
|
|
if (!mode) return false;
|
|
options = { ...options, mode };
|
|
continue;
|
|
}
|
|
case "localBranchAhead": {
|
|
const { local, remote } = outcome;
|
|
const r = await ask({
|
|
title: tx`Checkout ${remote}`,
|
|
body: tx`Local branch '${local}' has commits that do not exist in '${remote}'. Rebase '${local}' onto '${remote}', or drop local commits?`,
|
|
buttons: [
|
|
{ id: "rebase", label: "Rebase onto Remote", primary: true },
|
|
{ id: "reset", label: "Drop Local Commits" },
|
|
],
|
|
});
|
|
if (r.button !== "rebase" && r.button !== "reset") return false;
|
|
options = { ...options, diverged: r.button };
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Checkout of a commit or tag (detached HEAD) with Smart Checkout. */
|
|
export async function checkoutDetached(rev: string, label: string) {
|
|
await performCheckout(rev, () => `Checked out ${label} (detached HEAD)`);
|
|
}
|
|
|
|
export async function checkoutBranch(name: string) {
|
|
const state = useVcs.getState();
|
|
const previous = state.branch?.head;
|
|
// A local namesake tracking another branch: IDEA asks for a new local name instead.
|
|
if (state.branches.some((b) => b.remote && b.name === name)) {
|
|
const local = state.branches.find((b) => !b.remote && b.name === shortRemoteName(name));
|
|
if (local?.upstream && local.upstream !== name) return newBranch(name);
|
|
}
|
|
const ok = await performCheckout(name, () => `Checked out ${useVcs.getState().branch?.head ?? name}`);
|
|
if (ok) {
|
|
rememberBranch(previous);
|
|
await refreshNow();
|
|
rememberBranch(useVcs.getState().branch?.head);
|
|
}
|
|
}
|
|
|
|
/** New Branch: creates a branch from HEAD (or `from`) and, by default, checks it out. */
|
|
export async function newBranch(from?: string) {
|
|
const repo = currentRepo();
|
|
// IDEA opens the dialog with a suggestion instead of an empty field: a
|
|
// remote's name without the prefix, the source ref as `-copy`, or the
|
|
// current branch for a plain New Branch from HEAD.
|
|
const suggestion = from
|
|
? from.includes("/")
|
|
? shortRemoteName(from)
|
|
: `${from}-copy`
|
|
: (useVcs.getState().branch?.head ?? "");
|
|
const r = await promptText({
|
|
title: from ? tx`Create New Branch from ${from}` : "Create New Branch",
|
|
label: "New branch name:",
|
|
// Kept even when that branch exists (the current one always does): the user edits it.
|
|
initial: suggestion,
|
|
mono: true,
|
|
okLabel: "Create",
|
|
sanitize: cleanUpBranchNameOnTyping,
|
|
validate: validateBranchName,
|
|
validateInitial: true,
|
|
checkbox: { label: "Checkout branch", initial: true },
|
|
});
|
|
if (!r) return;
|
|
const checkout = r.checked;
|
|
const previous = useVcs.getState().branch?.head;
|
|
if (checkout) await saveAll();
|
|
const create = () => call(commands.vcsCreateBranch(repo, r.value, from ?? null, checkout));
|
|
const ok = await runOp(tx`Create branch ${r.value}`, create, {
|
|
success: checkout ? `Created and checked out ${r.value}` : `Created branch ${r.value}`,
|
|
});
|
|
if (ok !== undefined && checkout) {
|
|
rememberBranch(previous);
|
|
rememberBranch(r.value);
|
|
}
|
|
}
|
|
|
|
export async function checkoutRevision() {
|
|
const r = await promptText({
|
|
title: "Checkout Tag or Revision",
|
|
label: "Enter a tag, branch or commit hash:",
|
|
mono: true,
|
|
okLabel: "Checkout",
|
|
validate: (v) => (v.trim() ? null : "Enter a revision"),
|
|
});
|
|
if (!r) return;
|
|
await performCheckout(r.value, () => `Checked out ${r.value}`);
|
|
}
|
|
|
|
export function conflictActions(message: string) {
|
|
return conflictedFiles().length || /conflict/i.test(message)
|
|
? [{ label: "Resolve Conflicts…", run: () => void executeAction("vcs.resolveConflicts") }]
|
|
: [];
|
|
}
|
|
|
|
/**
|
|
* A merge / rebase that stopped on conflicts isn't an error in IDEA: it opens
|
|
* the Conflicts dialog instead of an error balloon.
|
|
*/
|
|
export async function openConflictsInstead(): Promise<boolean> {
|
|
await refreshNow();
|
|
if (!conflictedFiles().length) return false;
|
|
void executeAction("vcs.resolveConflicts");
|
|
return true;
|
|
}
|
|
|
|
export async function mergeIntoCurrent(branch: string, repo: string | null = currentRepo()) {
|
|
const current = (repo === currentRepo() ? useVcs.getState().branch?.head : null) ?? "HEAD";
|
|
await saveAll();
|
|
await runOp(tx`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(repo, branch)), {
|
|
success: tx`Merged ${branch} into ${current}`,
|
|
errorActions: conflictActions,
|
|
onError: openConflictsInstead,
|
|
});
|
|
}
|
|
|
|
export async function rebaseOnto(branch: string) {
|
|
const repo = currentRepo();
|
|
const current = useVcs.getState().branch?.head ?? "HEAD";
|
|
await saveAll();
|
|
await runOp(tx`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(repo, branch)), {
|
|
success: tx`Rebased ${current} onto ${branch}`,
|
|
errorActions: conflictActions,
|
|
onError: openConflictsInstead,
|
|
});
|
|
}
|
|
|
|
export async function renameBranch(name: string) {
|
|
const repo = currentRepo();
|
|
const r = await promptText({
|
|
title: tx`Rename Branch ${name}`,
|
|
label: "New branch name:",
|
|
initial: name,
|
|
mono: true,
|
|
okLabel: "Rename",
|
|
sanitize: cleanUpBranchNameOnTyping,
|
|
validate: (v) => (v.trim() === name ? "Enter a new name" : validateBranchName(v)),
|
|
});
|
|
if (!r) return;
|
|
const to = r.value.trim();
|
|
const ok = await runOp(tx`Rename branch ${name}`, () => call(commands.vcsRenameBranch(repo, name, to)), {
|
|
success: tx`Renamed branch ${name} to ${to}`,
|
|
});
|
|
if (ok !== undefined) {
|
|
try {
|
|
localStorage.setItem(recentKey(), JSON.stringify(recentBranches().map((b) => (b === name ? to : b))));
|
|
} catch {
|
|
// Recents are best effort.
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete Branch as IDEA does it: no confirmation; a branch that is not fully merged is
|
|
* deleted anyway and the notification says so, offering View Commits. Restore recreates
|
|
* the branch (with its upstream); Delete Tracked Branch removes the remote branch too.
|
|
*/
|
|
export async function deleteBranch(name: string) {
|
|
const repo = currentRepo();
|
|
const deleted = await runOp(tx`Delete branch ${name}`, () => call(commands.vcsDeleteBranch(repo, name)));
|
|
if (!deleted) return;
|
|
const tracked = deleted.upstream;
|
|
const otherTracking = useVcs.getState().branches.some((b) => !b.remote && b.name !== name && b.upstream === tracked);
|
|
const key = `vcs.deleted.${name}`;
|
|
const done = () => notifications().expire?.(key);
|
|
notifications().notify({
|
|
group: "vcs",
|
|
level: "info",
|
|
key,
|
|
title: `Deleted branch ${name}`,
|
|
body: deleted.unmerged ? "Unmerged commits were discarded" : undefined,
|
|
actions: [
|
|
{
|
|
label: "Restore",
|
|
run: () => {
|
|
done();
|
|
void runOp(tx`Restore branch ${name}`, () => call(commands.vcsRestoreBranch(repo, deleted)), {
|
|
success: tx`Restored branch ${name}`,
|
|
});
|
|
},
|
|
},
|
|
...(deleted.unmerged
|
|
? [{ label: "View Commits", run: () => showCommitRange(`Commits of ${name}`, "HEAD", deleted.tip) }]
|
|
: []),
|
|
...(tracked && !otherTracking
|
|
? [
|
|
{
|
|
label: "Delete Tracked Branch",
|
|
run: () => {
|
|
done();
|
|
void deleteRemoteBranch(tracked);
|
|
},
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
});
|
|
}
|
|
|
|
/** Deletes a branch on its remote (`git push <remote> --delete <name>`); `name` is e.g. `origin/feature`. */
|
|
export async function deleteRemoteBranch(name: string) {
|
|
const ok = await confirmDialog({
|
|
title: "Delete Remote Branch",
|
|
message: tx`Delete branch '${shortRemoteName(name)}' on the remote? The remote-tracking branch '${name}' is removed as well.`,
|
|
okLabel: "Delete",
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
await runRemote({ op: "deleteBranch", branch: name }, name);
|
|
}
|
|
|
|
/** Checkout and Rebase onto Current: checks out `name`, then rebases it onto the branch that was current. */
|
|
export async function checkoutAndRebase(name: string) {
|
|
const current = useVcs.getState().branch?.head;
|
|
if (!current) return;
|
|
const ok = await performCheckout(name, () => `Checked out ${name}`);
|
|
if (!ok) return;
|
|
rememberBranch(current);
|
|
rememberBranch(name);
|
|
await refreshNow();
|
|
await rebaseOnto(current);
|
|
}
|
|
|
|
/** Update for a branch other than the current one: fast-forwards it to its upstream. */
|
|
export async function updateBranch(name: string) {
|
|
const b = useVcs.getState().branches.find((x) => !x.remote && x.name === name);
|
|
if (b?.current) return void executeAction("vcs.update");
|
|
if (!b?.upstream) {
|
|
notifications().notify({ group: "vcs", level: "warning", title: `Branch '${name}' has no tracked branch` });
|
|
return;
|
|
}
|
|
await runRemote({ op: "updateBranch", branch: name }, `${name} ← ${b.upstream}`);
|
|
}
|
|
|
|
// --- several repositories ----------------------------------------------------
|
|
|
|
/** "in 3 repositories", or "in 2 of 3 repositories" when some were left out. */
|
|
function inRepos(done: number, total: number): string {
|
|
return done === total ? `in ${total} repositories` : `in ${done} of ${total} repositories`;
|
|
}
|
|
|
|
/**
|
|
* Runs `step` in each repository in turn, stopping at the first one that fails (its
|
|
* error is reported by `step`), then reports how many repositories it was done in.
|
|
*/
|
|
async function eachRepo(roots: readonly string[], step: (root: string) => Promise<boolean>, title: string) {
|
|
let done = 0;
|
|
for (const root of roots) {
|
|
if (!(await step(root))) break;
|
|
done++;
|
|
}
|
|
await refreshNow();
|
|
if (done) notifications().notify({ group: "vcs", level: "info", title: `${title} ${inRepos(done, roots.length)}` });
|
|
}
|
|
|
|
/** Checks out `name` (local, or remote: its local namesake) in every repository of `roots`. */
|
|
export async function checkoutInRepos(name: string, roots: readonly string[]) {
|
|
const label = useVcs.getState().branches.some((b) => b.remote && b.name === name) ? shortRemoteName(name) : name;
|
|
const previous = useVcs.getState().branch?.head;
|
|
await eachRepo(roots, (root) => performCheckout(name, () => "", root, true), `Checked out ${label}`);
|
|
rememberBranch(previous);
|
|
rememberBranch(useVcs.getState().branch?.head);
|
|
}
|
|
|
|
/** New Branch in every repository of `roots` (from HEAD or `from`), checked out by default. */
|
|
export async function newBranchInRepos(roots: readonly string[], from?: string) {
|
|
const r = await promptText({
|
|
title: from ? tx`Create New Branch from ${from}` : "Create New Branch",
|
|
label: tx`New branch name (in ${roots.length} repositories):`,
|
|
initial: from ? (from.includes("/") ? shortRemoteName(from) : "") : (useVcs.getState().branch?.head ?? ""),
|
|
mono: true,
|
|
okLabel: "Create",
|
|
sanitize: cleanUpBranchNameOnTyping,
|
|
validate: validateBranchName,
|
|
validateInitial: true,
|
|
checkbox: { label: "Checkout branch", initial: true },
|
|
});
|
|
if (!r) return;
|
|
const name = r.value.trim();
|
|
if (r.checked) await saveAll();
|
|
await eachRepo(
|
|
roots,
|
|
async (root) =>
|
|
(await runOp(
|
|
tx`Create branch ${name}`,
|
|
() => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)),
|
|
{
|
|
refresh: false,
|
|
},
|
|
)) !== undefined,
|
|
r.checked ? `Created and checked out ${name}` : `Created branch ${name}`,
|
|
);
|
|
}
|
|
|
|
/** Merges `branch` into the current branch of every repository of `roots`; stops on conflicts. */
|
|
export async function mergeInRepos(branch: string, roots: readonly string[]) {
|
|
await saveAll();
|
|
await eachRepo(
|
|
roots,
|
|
async (root) =>
|
|
(await runOp(tx`Merge ${branch} (${repoName(root)})`, () => call(commands.vcsMerge(root, branch)), {
|
|
refresh: false,
|
|
errorActions: conflictActions,
|
|
onError: openConflictsInstead,
|
|
})) !== undefined,
|
|
`Merged ${branch}`,
|
|
);
|
|
}
|
|
|
|
/** Rebases the current branch of every repository of `roots` onto `branch`; stops on conflicts. */
|
|
export async function rebaseInRepos(branch: string, roots: readonly string[]) {
|
|
await saveAll();
|
|
await eachRepo(
|
|
roots,
|
|
async (root) =>
|
|
(await runOp(tx`Rebase onto ${branch} (${repoName(root)})`, () => call(commands.vcsRebase(root, branch)), {
|
|
refresh: false,
|
|
errorActions: conflictActions,
|
|
onError: openConflictsInstead,
|
|
})) !== undefined,
|
|
`Rebased onto ${branch}`,
|
|
);
|
|
}
|
|
|
|
/** Deletes local branch `name` in every repository of `roots`. */
|
|
export async function deleteInRepos(name: string, roots: readonly string[]) {
|
|
const ok = await confirmDialog({
|
|
title: "Delete Branch",
|
|
message: tx`Delete branch '${name}' in ${roots.length} repositories? Unmerged commits are discarded.`,
|
|
okLabel: "Delete",
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
await eachRepo(
|
|
roots,
|
|
async (root) =>
|
|
(await runOp(tx`Delete branch ${name} (${repoName(root)})`, () => call(commands.vcsDeleteBranch(root, name)), {
|
|
refresh: false,
|
|
})) !== undefined,
|
|
`Deleted branch ${name}`,
|
|
);
|
|
}
|
|
|
|
// --- favorites ---------------------------------------------------------------
|
|
|
|
const favoritesKey = () => `openidea.vcs.favoriteBranches:${useVcs.getState().root ?? ""}`;
|
|
|
|
/** The user's favorite branches for the open repository; `null` = defaults (main / master). */
|
|
export const useFavoriteBranches = create<{ key: string; names: string[] | null }>(() => ({ key: "", names: null }));
|
|
|
|
export function loadFavoriteBranches() {
|
|
const key = favoritesKey();
|
|
if (useFavoriteBranches.getState().key === key) return;
|
|
let names: string[] | null = null;
|
|
try {
|
|
const raw = localStorage.getItem(key);
|
|
const v: unknown = raw ? JSON.parse(raw) : null;
|
|
if (Array.isArray(v)) names = v.filter((x): x is string => typeof x === "string");
|
|
} catch {
|
|
// Storage unavailable: defaults.
|
|
}
|
|
useFavoriteBranches.setState({ key, names });
|
|
}
|
|
|
|
export function toggleFavoriteBranch(b: Branch) {
|
|
loadFavoriteBranches();
|
|
const stored = useFavoriteBranches.getState().names;
|
|
// First edit: start from the defaults that are currently shown.
|
|
const base =
|
|
stored ??
|
|
useVcs
|
|
.getState()
|
|
.branches.filter((x) => isFavoriteBranch(x, null))
|
|
.map((x) => x.name);
|
|
const names = base.includes(b.name) ? base.filter((n) => n !== b.name) : [...base, b.name];
|
|
useFavoriteBranches.setState({ names });
|
|
try {
|
|
localStorage.setItem(favoritesKey(), JSON.stringify(names));
|
|
} catch {
|
|
// Favorites last for the session only.
|
|
}
|
|
}
|