diff --git a/src/features/vcs/__tests__/branchOps.test.ts b/src/features/vcs/__tests__/branchOps.test.ts index e6899b2f1..be0c1e78a 100644 --- a/src/features/vcs/__tests__/branchOps.test.ts +++ b/src/features/vcs/__tests__/branchOps.test.ts @@ -1,5 +1,5 @@ import { beforeEach, expect, it, vi } from "vitest"; -import type { BranchInfo } from "@/bindings"; +import type { Branch, BranchInfo } from "@/bindings"; const mocks = vi.hoisted(() => ({ prompt: vi.fn() })); vi.mock("../dialogs", async (original) => ({ ...(await original()), @@ -17,9 +17,25 @@ const branch = (head: string | null): BranchInfo => ({ operation: null, }); +const local = (name: string, current = false): Branch => ({ + name, + remote: false, + current, + upstream: null, + ahead: 0, + behind: 0, + lastCommitMs: null, +}); + beforeEach(() => { vi.clearAllMocks(); - useVcs.setState({ root: "/p", repo: "/p/front", branch: branch("feature/ui") }); + // The current branch is always among the local branches, so the suggestion names an existing one. + useVcs.setState({ + root: "/p", + repo: "/p/front", + branch: branch("feature/ui"), + branches: [local("feature/ui", true), local("main")], + }); // Cancel: only what the dialog was offered matters here, not its outcome. mocks.prompt.mockResolvedValue(null); }); @@ -27,6 +43,7 @@ beforeEach(() => { it("prefills a plain New Branch with the current branch name", async () => { await newBranch(); expect(mocks.prompt.mock.calls[0]?.[0].initial).toBe("feature/ui"); + expect(mocks.prompt.mock.calls[0]?.[0].validateInitial).toBe(true); }); it("prefills nothing when HEAD is detached", async () => { diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index 6bd3f894c..ab8c294c2 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -156,10 +156,12 @@ export async function newBranch(from?: string) { const r = await promptText({ title: from ? tx`Create New Branch from ${from}` : "Create New Branch", label: "New branch name:", - initial: suggestion && !validateBranchName(suggestion) ? suggestion : "", + // Kept even when that branch exists (the current one always does): the user edits it. + initial: suggestion, mono: true, okLabel: "Create", validate: validateBranchName, + validateInitial: true, checkbox: { label: "Checkout branch", initial: true }, }); if (!r) return; @@ -371,6 +373,7 @@ export async function newBranchInRepos(roots: readonly string[], from?: string) mono: true, okLabel: "Create", validate: validateBranchName, + validateInitial: true, checkbox: { label: "Checkout branch", initial: true }, }); if (!r) return; diff --git a/src/features/vcs/dialogs.tsx b/src/features/vcs/dialogs.tsx index 401c0236e..45a34a569 100644 --- a/src/features/vcs/dialogs.tsx +++ b/src/features/vcs/dialogs.tsx @@ -43,6 +43,8 @@ export interface PromptOptions { mono?: boolean; /** Returns an error message, or null when valid. */ validate?: (value: string) => string | null; + /** Show the prefilled value's problem (e.g. the branch exists) before it is edited. */ + validateInitial?: boolean; /** Optional checkbox below the field. */ checkbox?: { label: string; initial: boolean }; } @@ -70,7 +72,7 @@ function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null) const { title, label, initial = "", placeholder, okLabel = "OK", mono, validate, checkbox, onDone } = props; const [value, setValue] = useState(initial); const [checked, setChecked] = useState(checkbox?.initial ?? false); - const [touched, setTouched] = useState(false); + const [touched, setTouched] = useState(!!props.validateInitial && initial !== ""); const error = validate ? validate(value) : value.trim() ? null : "Value must not be empty"; const submit = () => { setTouched(true);