feat(vcs): replace spaces typed in a branch name with a separator (#1571)

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]>
Цей коміт міститься в:
JawтаClaude Opus 5.5 authored and GitHub committed 2026-11-05 23:09:55 +08:00
1 джерело c179b1a822
коміт d1acf9f9ac
12 files changed
+310 -8

No files matched your search

+70
Переглянути файл
@@ -0,0 +1,70 @@
/**
* Create New Branch: spaces typed or pasted into the name become separators on the fly, as in
* IDEA, so the name stays valid. Git is stubbed at the IPC layer.
*/
import type { Page } from "@playwright/test";
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
async function openNewBranchDialog(page: Page) {
await stubDesktop(page);
await page.evaluate(async (root) => {
const h = window.__ipc.handlers;
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
h.vcs_branch_info = () => ({
head: "main",
headCommit: "1111111",
upstream: null,
ahead: 0,
behind: 0,
operation: null,
});
h.vcs_status = () => [];
h.vcs_create_branch = () => null;
const vcsModule = "/src/features/vcs/store.ts";
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
await refreshNow();
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
void executeAction("vcs.newBranch");
}, ROOT);
const dialog = page.getByRole("dialog", { name: "Create New Branch" });
const input = dialog.getByRole("textbox", { name: "New branch name:" });
await expect(input).toBeVisible();
return { dialog, input };
}
test("a typed space becomes a separator and Create stays enabled", async ({ workbench: page }) => {
const { dialog, input } = await openNewBranchDialog(page);
await input.fill("");
await input.pressSequentially("fix login redirect");
await expect(input).toHaveValue("fix-login-redirect");
await expect(dialog.getByText("Branch name contains invalid characters")).toHaveCount(0);
await expect(dialog.getByRole("button", { name: "Create", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "Create", exact: true }).click();
const [call] = await ipcCalls(page, "vcs_create_branch");
expect(call).toMatchObject({ name: "fix-login-redirect" });
});
test("a space typed in the middle keeps the caret after it", async ({ workbench: page }) => {
const { input } = await openNewBranchDialog(page);
await input.fill("ab");
await input.evaluate((el: HTMLInputElement) => el.setSelectionRange(1, 1));
await page.keyboard.press("Space");
await expect(input).toHaveValue("a-b");
await page.keyboard.type("x");
await expect(input).toHaveValue("a-xb");
});
test("pasted text has its spaces replaced", async ({ workbench: page }) => {
const { dialog, input } = await openNewBranchDialog(page);
await input.fill("");
await input.evaluate((el: HTMLInputElement) => {
el.focus();
document.execCommand("insertText", false, "feature/add new thing");
});
await expect(input).toHaveValue("feature/add-new-thing");
await expect(dialog.getByRole("button", { name: "Create", exact: true })).toBeEnabled();
});
+19
Переглянути файл
@@ -0,0 +1,19 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it } from "vitest";
import { GithubDialogHost } from "../ui/dialogs";
import { remoteBranchNameDialog } from "./RemoteBranchDialog";
it("turns spaces typed in the remote branch name into separators", async () => {
const user = userEvent.setup();
render(<GithubDialogHost />);
let result: Promise<string | null> | undefined;
act(() => {
result = remoteBranchNameDialog("");
});
const input = await screen.findByRole("textbox", { name: "Remote branch name" });
await user.type(input, "my feature");
expect(input).toHaveValue("my-feature");
await user.click(screen.getByRole("button", { name: "OK" }));
await expect(result).resolves.toBe("my-feature");
});
+10 -2
Переглянути файл
@@ -1,6 +1,7 @@
/** "New Remote Branch for Pull Request": the name to push a local head without a push target to. */
import { useState } from "react";
import { Dialog, Input, T } from "@/ui";
import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName";
import { Dialog, Input, T, useSanitizedField } from "@/ui";
import { openDialog } from "../ui/dialogs";
import styles from "./Create.module.css";
@@ -11,6 +12,7 @@ export function remoteBranchNameDialog(suggested: string): Promise<string | null
function RemoteBranchDialog(props: { suggested: string; onDone: (name: string | null) => void }) {
const [name, setName] = useState(props.suggested);
const { ref, clean } = useSanitizedField(cleanUpBranchNameOnTyping);
const valid = !!name.trim();
return (
<Dialog
@@ -33,7 +35,13 @@ function RemoteBranchDialog(props: { suggested: string; onDone: (name: string |
<label className={styles.dialogLabel}>
<T>Remote branch name:</T>
</label>
<Input data-autofocus aria-label="Remote branch name" value={name} onChange={(e) => setName(e.target.value)} />
<Input
ref={ref}
data-autofocus
aria-label="Remote branch name"
value={name}
onChange={(e) => setName(clean(e))}
/>
<div className={styles.dialogComment}>
<T>Your changes will be pushed to the remote repository to create the pull request</T>
</div>
+14 -1
Переглянути файл
@@ -5,7 +5,7 @@ vi.mock("../dialogs", async (original) => ({
...(await original<typeof import("../dialogs")>()),
promptText: mocks.prompt,
}));
import { newBranch, newBranchInRepos } from "../branchOps";
import { newBranch, newBranchInRepos, renameBranch } from "../branchOps";
import { useVcs } from "../store";
const branch = (head: string | null): BranchInfo => ({
@@ -61,3 +61,16 @@ it("prefills the current branch in the multi-repository dialog too", async () =>
await newBranchInRepos(["/p/front", "/p/back"]);
expect(mocks.prompt.mock.calls[0]?.[0].initial).toBe("feature/ui");
});
it.each([
["New Branch", () => newBranch()],
["New Branch from a revision", () => newBranch("origin/main")],
["New Branch in several repositories", () => newBranchInRepos(["/p/front", "/p/back"])],
["Rename Branch", () => renameBranch("main")],
])("%s turns a typed space into a separator, as IDEA does", async (_name, open) => {
await open();
const { sanitize, validate } = mocks.prompt.mock.calls[0]![0];
const typed: string = sanitize("fix login");
expect(typed).toBe("fix-login");
expect(validate(typed)).toBeNull();
});
+64
Переглянути файл
@@ -0,0 +1,64 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName";
import { VcsDialogHost, promptText } from "../dialogs";
import { validateBranchName } from "../branchOps";
const open = (initial = "") =>
promptText({
title: "Create New Branch",
label: "New branch name:",
initial,
okLabel: "Create",
sanitize: cleanUpBranchNameOnTyping,
validate: validateBranchName,
});
// The dialog stack is module state: close whatever a test left open.
afterEach(async () => {
for (const cancel of screen.queryAllByRole("button", { name: "Cancel" })) await userEvent.click(cancel);
});
describe("prompt dialog with a sanitizer (branch name)", () => {
it("replaces a typed space and keeps Create enabled", async () => {
const user = userEvent.setup();
render(<VcsDialogHost />);
let result: ReturnType<typeof open> | undefined;
act(() => {
result = open();
});
const input = await screen.findByRole("textbox", { name: "New branch name:" });
await user.type(input, "fix login redirect");
expect(input).toHaveValue("fix-login-redirect");
expect(screen.queryByText("Branch name contains invalid characters")).toBeNull();
const create = screen.getByRole("button", { name: "Create" });
expect(create).toBeEnabled();
await user.click(create);
await expect(result).resolves.toEqual({ value: "fix-login-redirect", checked: false });
});
it("converts spaces in pasted text", async () => {
const user = userEvent.setup();
render(<VcsDialogHost />);
act(() => void open());
const input = await screen.findByRole("textbox", { name: "New branch name:" });
await user.click(input);
await user.paste("feature/add new thing");
expect(input).toHaveValue("feature/add-new-thing");
expect(screen.getByRole("button", { name: "Create" })).toBeEnabled();
});
it("keeps the caret after the rewritten character when editing mid-text", async () => {
const user = userEvent.setup();
render(<VcsDialogHost />);
act(() => void open("ab"));
const input = await screen.findByRole<HTMLInputElement>("textbox", { name: "New branch name:" });
input.setSelectionRange(1, 1);
await user.keyboard(" ");
expect(input).toHaveValue("a-b");
await waitFor(() => expect(input.selectionStart).toBe(2));
await user.keyboard("x");
expect(input).toHaveValue("a-xb");
});
});
+4
Переглянути файл
@@ -8,6 +8,7 @@ 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";
@@ -160,6 +161,7 @@ export async function newBranch(from?: string) {
initial: suggestion,
mono: true,
okLabel: "Create",
sanitize: cleanUpBranchNameOnTyping,
validate: validateBranchName,
validateInitial: true,
checkbox: { label: "Checkout branch", initial: true },
@@ -236,6 +238,7 @@ export async function renameBranch(name: string) {
initial: name,
mono: true,
okLabel: "Rename",
sanitize: cleanUpBranchNameOnTyping,
validate: (v) => (v.trim() === name ? "Enter a new name" : validateBranchName(v)),
});
if (!r) return;
@@ -372,6 +375,7 @@ export async function newBranchInRepos(roots: readonly string[], from?: string)
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 },
+7 -3
Переглянути файл
@@ -1,7 +1,7 @@
/** Imperative dialogs for VCS actions, rendered by one overlay contribution. */
import { Fragment, useState, type ReactNode } from "react";
import { create } from "zustand";
import { Checkbox, Dialog, Input, T } from "@/ui";
import { Checkbox, Dialog, Input, T, useSanitizedField } from "@/ui";
import { confirm } from "@/workbench/messages";
import styles from "./common.module.css";
@@ -43,6 +43,8 @@ export interface PromptOptions {
mono?: boolean;
/** Returns an error message, or null when valid. */
validate?: (value: string) => string | null;
/** Rewrites the field as the user types or pastes (e.g. spaces in a branch name). */
sanitize?: (value: string) => string;
/** Show the prefilled value's problem (e.g. the branch exists) before it is edited. */
validateInitial?: boolean;
/** Optional checkbox below the field. */
@@ -69,7 +71,8 @@ export function promptText(opts: PromptOptions): Promise<PromptResult | null> {
}
function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null) => void }) {
const { title, label, initial = "", placeholder, okLabel = "OK", mono, validate, checkbox, onDone } = props;
const { title, label, initial = "", placeholder, okLabel = "OK", mono, validate, sanitize, checkbox, onDone } = props;
const { ref, clean } = useSanitizedField(sanitize);
const [value, setValue] = useState(initial);
const [checked, setChecked] = useState(checkbox?.initial ?? false);
const [touched, setTouched] = useState(!!props.validateInitial && initial !== "");
@@ -102,6 +105,7 @@ function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null)
</label>
)}
<Input
ref={ref}
data-autofocus
aria-label={label ?? title}
value={value}
@@ -110,7 +114,7 @@ function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null)
invalid={touched && !!error}
onChange={(e) => {
setTouched(true);
setValue(e.target.value);
setValue(clean(e));
}}
/>
{touched && error && value && <div className={styles.error}>{error}</div>}
+5 -2
Переглянути файл
@@ -5,9 +5,10 @@ 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, type SelectOption } from "@/ui";
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";
@@ -687,6 +688,7 @@ function NewWorktreeDialog({ onClose }: { onClose: (created: boolean) => void })
const [source, setSource] = useState<string | null>(null);
const [createBranch, setCreateBranch] = useState(true);
const [newBranch, setNewBranch] = useState("");
const { ref: branchRef, clean: cleanBranch } = useSanitizedField(cleanUpBranchNameOnTyping);
const [path, setPath] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const options = useMemo(
@@ -744,11 +746,12 @@ function NewWorktreeDialog({ onClose }: { onClose: (created: boolean) => void })
<Checkbox label="Create a new branch" checked={createBranch} onChange={setCreateBranch} />
{createBranch && (
<Input
ref={branchRef}
aria-label="New branch name"
mono
value={newBranch}
placeholder={suggestedBranch}
onChange={(e) => setNewBranch(e.target.value)}
onChange={(e) => setNewBranch(cleanBranch(e))}
/>
)}
<div className={styles.fieldLabel}>
+50
Переглянути файл
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { cleanUpBranchNameOnTyping } from "./gitRefName";
describe("cleanUpBranchNameOnTyping", () => {
it.each([
// The reported case: a space typed or pasted between words.
["feature x", "feature-x"],
["fix login redirect", "fix-login-redirect"],
// Repeated spaces collapse to one separator.
["a b", "a-b"],
["feature ", "feature-"],
// Space pressed again after the substituted separator, and spaces around a typed one.
["feature- ", "feature-"],
["feature - x", "feature-x"],
["a-b", "a-b"],
["a--b", "a--b"],
// A leading space would form a leading `-`, which git rejects: dropped.
[" feature", "feature"],
[" ", ""],
// Tabs and other control characters count as whitespace too.
["a\tb", "a-b"],
["a\u007fb", "a-b"],
// Characters git forbids are dropped rather than substituted.
["a~b^c:d?e*f[g\\h", "abcdefgh"],
['say "hi"', "say-hi"],
["a@{b", "ab"],
["a..b", "a.b"],
["a//b", "a/b"],
["/a", "a"],
[".a", "a"],
["-a", "a"],
["a/.b", "a/b"],
["a./b", "a/b"],
// Already valid names are untouched, including Unicode.
["feature/login-v2", "feature/login-v2"],
["release/1.2.3", "release/1.2.3"],
["fix_bug", "fix_bug"],
["功能/登录", "功能/登录"],
["", ""],
])("%j → %j", (input, expected) => {
expect(cleanUpBranchNameOnTyping(input)).toBe(expected);
});
it("is idempotent, so a cleaned field is stable under re-typing", () => {
for (const s of ["a b", " a", "a .. b", "@{ x", "x/ /y", "- - a"]) {
const once = cleanUpBranchNameOnTyping(s);
expect(cleanUpBranchNameOnTyping(once)).toBe(once);
}
});
});
+32
Переглянути файл
@@ -0,0 +1,32 @@
/** Ports IntelliJ's `GitRefNameValidator.cleanUpBranchNameOnTyping` (git4idea). */
/** IntelliJ's advanced setting `git.branch.cleanup.symbol` (default `-`). */
export const BRANCH_CLEANUP_SYMBOL = "-";
// Characters and sequences git forbids that cannot be repaired by substitution are dropped.
const DROP = /^\.|^-|^\/|[~:^?*"[\\]+|(?:@\{)+|\/(?=\/)|(?:\.(?=\.))+|\.(?=\/)/g;
// IntelliJ drops a dot after a slash with a lookbehind, which pre-16.4 WebKit cannot parse.
const DOT_AFTER_SLASH = /\/\./g;
// A run of whitespace / control characters becomes one separator. It also absorbs separators
// mixed into or next to it, so pressing Space again after the substituted one doesn't stack them (IntelliJ
// collapses only spaces typed in one go).
const SEPARATOR = BRANCH_CLEANUP_SYMBOL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const REPLACE = new RegExp(`(?:${SEPARATOR}|[ \\p{Cc}])*[ \\p{Cc}](?:${SEPARATOR}|[ \\p{Cc}])*`, "gu");
function cleanOnce(name: string): string {
return name.replace(DROP, "").replace(DOT_AFTER_SLASH, "/").replace(REPLACE, BRANCH_CLEANUP_SYMBOL);
}
/**
* Makes `name` typable as a branch name: forbidden characters are dropped and spaces become the
* cleanup symbol, so a space never reaches the validator. Runs to a fixed point because the
* substitution can itself form a forbidden start (" x" → "-x" → "x").
*/
export function cleanUpBranchNameOnTyping(name: string): string {
let cleaned = name;
for (;;) {
const next = cleanOnce(cleaned);
if (next === cleaned) return cleaned;
cleaned = next;
}
}
+1
Переглянути файл
@@ -36,4 +36,5 @@ export { lazyComponent, prefetchLazyComponents } from "./lazy";
export { T } from "./T";
export { selectableText, copyMenuFor, copyText } from "./selectableText";
export { useSelectionBoundary } from "./useSelectionBoundary";
export { useSanitizedField } from "./useSanitizedField";
export * from "./rowSelection";
+34
Переглянути файл
@@ -0,0 +1,34 @@
import { useLayoutEffect, useRef, type ChangeEvent } from "react";
/**
* Rewrites what the user types or pastes into a text input as IntelliJ does for ref names,
* keeping the caret where the typed character was. Use as
* `<Input ref={ref} onChange={(e) => setValue(clean(e))} />`.
*/
export function useSanitizedField(sanitize?: (value: string) => string) {
const ref = useRef<HTMLInputElement>(null);
const caret = useRef<number | null>(null);
const place = () => {
if (caret.current === null) return;
ref.current?.setSelectionRange(caret.current, caret.current);
caret.current = null;
};
// A controlled input whose text was rewritten loses its caret on re-render.
useLayoutEffect(place);
const clean = (e: ChangeEvent<HTMLInputElement>): string => {
const raw = e.target.value;
const value = sanitize ? sanitize(raw) : raw;
if (value !== raw) {
const at = e.target.selectionStart ?? raw.length;
caret.current = sanitize ? sanitize(raw.slice(0, at)).length : at;
// Nothing re-renders when the rewrite equals the current state (a dropped character), and
// React then puts the caret at the end once this handler returns: a microtask runs after that.
queueMicrotask(place);
}
return value;
};
return { ref, clean };
}