(null);
const options = useMemo(
@@ -744,11 +746,12 @@ function NewWorktreeDialog({ onClose }: { onClose: (created: boolean) => void })
{createBranch && (
setNewBranch(e.target.value)}
+ onChange={(e) => setNewBranch(cleanBranch(e))}
/>
)}
diff --git a/src/platform/gitRefName.test.ts b/src/platform/gitRefName.test.ts
new file mode 100644
index 000000000..4fadbcce4
--- /dev/null
+++ b/src/platform/gitRefName.test.ts
@@ -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);
+ }
+ });
+});
diff --git a/src/platform/gitRefName.ts b/src/platform/gitRefName.ts
new file mode 100644
index 000000000..5802a54be
--- /dev/null
+++ b/src/platform/gitRefName.ts
@@ -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;
+ }
+}
diff --git a/src/ui/index.ts b/src/ui/index.ts
index 5633d0a1d..8cab09729 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -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";
diff --git a/src/ui/useSanitizedField.ts b/src/ui/useSanitizedField.ts
new file mode 100644
index 000000000..fd684608e
--- /dev/null
+++ b/src/ui/useSanitizedField.ts
@@ -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
+ * ` setValue(clean(e))} />`.
+ */
+export function useSanitizedField(sanitize?: (value: string) => string) {
+ const ref = useRef(null);
+ const caret = useRef(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): 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 };
+}