diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bcc8b82f..b3df5d8d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 machine with no agent it opened a card that died on `claude: command not found`. It now opens a shell — where the install command from those links can be pasted — and says so before you press it. +- **The new worktree can be named in plain words.** The name field only ever + took a branch name: anything with a space in it was refused as invalid, so + naming the work meant inventing the hyphens yourself, and leaving the field + blank handed the branch a name like `calm-glacier` — which is fine until it + is the name on a pull request, and renaming a branch after the PR is open + closes the PR. Type what the work is instead — `fix the auth redirect` — and + the branch becomes `fix-the-auth-redirect`, shown under the field before + anything is created. A name git already accepts is still used exactly as + typed, and a blank field still auto-generates one. - **A project tab comes back to the screen you left it on.** Stepping over to another project and back always dropped you onto the first project's terminals, whatever you had been reading there: its Settings, the pull request diff --git a/frontend/src/components/sidebar/WorktreeDialog.tsx b/frontend/src/components/sidebar/WorktreeDialog.tsx index ec5867eac..e98005bed 100644 --- a/frontend/src/components/sidebar/WorktreeDialog.tsx +++ b/frontend/src/components/sidebar/WorktreeDialog.tsx @@ -17,7 +17,7 @@ import { } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { isValidBranchName } from "@/lib/git/branch-name" +import { toBranchName } from "@/lib/git/branch-name" import { useSandboxChoice, type SandboxAnswer } from "@/lib/use-sandbox-choice" import { cn, errorText } from "@/lib/utils" @@ -110,10 +110,10 @@ function Group({ title, items, base, onSelect }: GroupProps) { ) } -// WorktreeDialog collects a worktree name (blank = random adjective-noun) and a -// base picked from a searchable list — existing worktrees to resume, then local -// and remote branches (remote bases are fetched and tracked). It stays open on -// failure so git's error is readable in place. +// WorktreeDialog collects a worktree name (blank = random adjective-noun, plain +// words = a slug of them) and a base picked from a searchable list — existing +// worktrees to resume, then local and remote branches (remote bases are fetched +// and tracked). It stays open on failure so git's error is readable in place. export function WorktreeDialog({ open, onOpenChange, @@ -138,8 +138,9 @@ export function WorktreeDialog({ const vis = filterBranches(branches, filter) const flat = flatValues(vis) const noMatches = branches !== null && flat.length === 0 - const trimmed = name.trim() - const nameInvalid = trimmed !== "" && !isValidBranchName(trimmed) + // What the typed name creates: itself when git takes it, a slug of the first + // few words when it is a phrase, blank when nothing usable was typed. + const newBranch = toBranchName(name) const isResume = base.startsWith("worktree:") // Keep the selected base in view as it changes — the preselected current @@ -208,7 +209,7 @@ export function WorktreeDialog({ move(-1) } else if (event.key === "Enter") { event.preventDefault() - if (!nameInvalid && base && !submitting) { + if (base && !submitting) { void submit() } } @@ -228,7 +229,7 @@ export function WorktreeDialog({ setSubmitting(true) setSubmitError("") try { - await onCreate(trimmed, id, group === "remote", sandbox.answer) + await onCreate(newBranch, id, group === "remote", sandbox.answer) } catch (err) { setSubmitError(errorText(err)) setSubmitting(false) @@ -255,20 +256,15 @@ export function WorktreeDialog({ id="worktree-name" value={name} onChange={(e) => setName(e.target.value)} - placeholder="Leave blank to auto-generate (e.g. swift-rabbit)" + placeholder="A branch name, or the task in plain words (e.g. fix the auth redirect)" disabled={isResume} - aria-invalid={nameInvalid || undefined} autoFocus /> - {nameInvalid ? ( - Invalid branch name - ) : ( - - {isResume - ? "Opens the selected worktree" - : `Branch: ${trimmed || ""}`} - - )} + + {isResume + ? "Opens the selected worktree" + : `Branch: ${newBranch || ""}`} +
@@ -346,7 +342,7 @@ export function WorktreeDialog({ }>Cancel - diff --git a/frontend/src/lib/git/branch-name.test.ts b/frontend/src/lib/git/branch-name.test.ts index 558d29b64..b4e085df1 100644 --- a/frontend/src/lib/git/branch-name.test.ts +++ b/frontend/src/lib/git/branch-name.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { isValidBranchName } from "./branch-name" +import { isValidBranchName, toBranchName } from "./branch-name" describe("isValidBranchName", () => { const valid = ["main", "feat/x", "a-b_c.d", "v1.0", "wt/deep/nesting", "UPPER"] @@ -35,3 +35,49 @@ describe("isValidBranchName", () => { expect(isValidBranchName(name)).toBe(false) }) }) + +describe("toBranchName", () => { + const kept = ["main", "feat/x", "a-b_c.d", "wt/deep/nesting", "!!!"] + it.each(kept)("keeps %j, which git already accepts", (name) => { + expect(toBranchName(name)).toBe(name) + }) + + it("trims before deciding", () => { + expect(toBranchName(" feat/x ")).toBe("feat/x") + }) + + it("slugifies a typed phrase", () => { + expect(toBranchName("Fix the auth redirect loop")).toBe("fix-the-auth-redirect-loop") + }) + + it("keeps letters that are not ASCII", () => { + expect(toBranchName("corrigir a sessão órfã")).toBe("corrigir-a-sessão-órfã") + }) + + it("stops at five words", () => { + expect(toBranchName("one two three four five six")).toBe("one-two-three-four-five") + }) + + it("stops before the character maximum once the minimum is met", () => { + expect(toBranchName("refactor the authentication middleware completely")).toBe( + "refactor-the-authentication-middleware", + ) + }) + + it("grows past the character maximum while under the character minimum", () => { + expect(toBranchName(`go on ${"z".repeat(40)}`)).toBe(`go-on-${"z".repeat(34)}`) + }) + + it("clamps a word that alone runs past the maximum", () => { + expect(toBranchName(`${"z".repeat(50)} x`)).toBe("z".repeat(40)) + }) + + it("leaves no trailing separator when the clamp lands on one", () => { + expect(toBranchName(`${"a".repeat(39)} b`)).toBe("a".repeat(39)) + }) + + const nothing = ["", " ", "@", "..."] + it.each(nothing)("returns nothing for %j, so the caller auto-generates", (input) => { + expect(toBranchName(input)).toBe("") + }) +}) diff --git a/frontend/src/lib/git/branch-name.ts b/frontend/src/lib/git/branch-name.ts index af31834a2..8d905452e 100644 --- a/frontend/src/lib/git/branch-name.ts +++ b/frontend/src/lib/git/branch-name.ts @@ -27,3 +27,52 @@ export function isValidBranchName(name: string): boolean { part !== "" && !part.startsWith(".") && !part.endsWith(".") && !part.endsWith(".lock"), ) } + +// A name typed as words is grown one word at a time: to at least a minimum, so +// a run of very short words still reads as a phrase, and stopped before a +// maximum, so a pasted sentence does not become the branch. Both bounds are +// counted in words and in characters — either one alone lets the other run away. +const MIN_WORDS = 2 +const MAX_WORDS = 5 +const MIN_CHARS = 10 +const MAX_CHARS = 40 + +/** + * The branch a typed worktree name creates: what was typed when git already + * accepts it, a kebab-case slug of the first few words when it does not, and + * "" when nothing usable is left — which is the same blank the caller + * auto-generates a name for. + * + * The point is the name a branch carries into a pull request. A blank field + * takes the random adjective-noun, and renaming that branch after the PR is + * open closes the PR, so the only cheap moment to say what the work is called + * is before the worktree exists. Typing the task in plain words is that moment. + */ +export function toBranchName(input: string): string { + const trimmed = input.trim() + if (trimmed === "" || isValidBranchName(trimmed)) { + return trimmed + } + // Letters and numbers in any script, not just ASCII: git takes a non-ASCII + // branch name, and stripping the accents off a phrase would spell it wrong. + const words = trimmed + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim() + .split(/\s+/) + .filter(Boolean) + const picked: string[] = [] + let length = 0 + for (const word of words) { + const next = length === 0 ? word.length : length + 1 + word.length + const haveMinimum = picked.length >= MIN_WORDS && length >= MIN_CHARS + if (picked.length >= MAX_WORDS || (next > MAX_CHARS && haveMinimum)) { + break + } + picked.push(word) + length = next + } + // A single word longer than the maximum is picked before the bound applies — + // it is that or nothing — so the clamp is enforced here, on the joined name. + return picked.join("-").slice(0, MAX_CHARS).replace(/-+$/, "") +}