auto select wt

This commit is contained in:
Neil 2026-03-18 21:14:23 -07:00
parent f595b7d473
commit 4d460a6ed0
4 changed files with 157 additions and 22 deletions

View file

@ -245,3 +245,18 @@ async function hasGitRefAsync(path: string, ref: string): Promise<boolean> {
return false
}
}
export async function getAvailableBranchName(path: string, branchName: string): Promise<string> {
if (!(await hasGitRefAsync(path, `refs/heads/${branchName}`))) {
return branchName
}
let suffix = 1
while (true) {
const candidate = `${branchName}-${suffix}`
if (!(await hasGitRefAsync(path, `refs/heads/${candidate}`))) {
return candidate
}
suffix += 1
}
}

View file

@ -3,7 +3,7 @@ import { join, basename } from 'path'
import type { Store } from '../persistence'
import type { Worktree, WorktreeMeta } from '../../shared/types'
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
import { getGitUsername, getDefaultBaseRef } from '../git/repo'
import { getGitUsername, getDefaultBaseRef, getAvailableBranchName } from '../git/repo'
import { getEffectiveHooks, loadHooks, runHook, hasHooksFile } from '../hooks'
export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store): void {
@ -54,13 +54,16 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
branchName = `${settings.branchPrefixCustom}/${args.name}`
}
const requestedName = args.name
branchName = await getAvailableBranchName(repo.path, branchName)
// Compute worktree path
let worktreePath: string
if (settings.nestWorkspaces) {
const repoName = basename(repo.path).replace(/\.git$/, '')
worktreePath = join(settings.workspaceDir, repoName, args.name)
worktreePath = join(settings.workspaceDir, repoName, requestedName)
} else {
worktreePath = join(settings.workspaceDir, args.name)
worktreePath = join(settings.workspaceDir, requestedName)
}
// Determine base branch
@ -73,7 +76,12 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
const created = gitWorktrees.find((gw) => gw.path === worktreePath)
if (!created) throw new Error('Worktree created but not found in listing')
const worktree = mergeWorktree(repo.id, created, undefined)
const worktreeId = `${repo.id}::${worktreePath}`
const meta =
branchName === requestedName
? undefined
: store.setWorktreeMeta(worktreeId, { displayName: requestedName })
const worktree = mergeWorktree(repo.id, created, meta)
// Run setup hook asynchronously (don't block the UI)
const hooks = getEffectiveHooks(repo)

View file

@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react'
import React, { useState, useCallback, useMemo, useRef } from 'react'
import { useAppStore } from '@/store'
import {
Dialog,
@ -19,6 +19,7 @@ import {
} from '@/components/ui/select'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
import { SPACE_NAMES } from '@/constants/space-names'
const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
const activeModal = useAppStore((s) => s.activeModal)
@ -27,6 +28,8 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
const repos = useAppStore((s) => s.repos)
const createWorktree = useAppStore((s) => s.createWorktree)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const setActiveRepo = useAppStore((s) => s.setActiveRepo)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveView = useAppStore((s) => s.setActiveView)
@ -35,17 +38,29 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
const setShowActiveOnly = useAppStore((s) => s.setShowActiveOnly)
const setFilterRepoId = useAppStore((s) => s.setFilterRepoId)
const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const settings = useAppStore((s) => s.settings)
const [repoId, setRepoId] = useState<string>('')
const [name, setName] = useState('')
const [linkedIssue, setLinkedIssue] = useState('')
const [comment, setComment] = useState('')
const [creating, setCreating] = useState(false)
const nameInputRef = useRef<HTMLInputElement>(null)
const lastSuggestedNameRef = useRef('')
const isOpen = activeModal === 'create-worktree'
const preselectedRepoId =
typeof modalData.preselectedRepoId === 'string' ? modalData.preselectedRepoId : ''
const activeWorktreeRepoId = useMemo(
() => findRepoIdForWorktree(activeWorktreeId, worktreesByRepo),
[activeWorktreeId, worktreesByRepo]
)
const selectedRepo = repos.find((r) => r.id === repoId)
const suggestedName = useMemo(
() => getSuggestedSpaceName(repoId, worktreesByRepo, settings?.nestWorkspaces ?? false),
[repoId, worktreesByRepo, settings?.nestWorkspaces]
)
const handleOpenChange = useCallback(
(open: boolean) => {
@ -55,6 +70,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
setName('')
setLinkedIssue('')
setComment('')
lastSuggestedNameRef.current = ''
}
},
[closeModal]
@ -111,7 +127,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
handleOpenChange
])
// Auto-select first repo when opening
// Auto-select repo when opening.
React.useEffect(() => {
if (!isOpen || repos.length === 0) return
@ -120,10 +136,37 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
return
}
if (activeWorktreeRepoId && repos.some((repo) => repo.id === activeWorktreeRepoId)) {
setRepoId(activeWorktreeRepoId)
return
}
if (activeRepoId && repos.some((repo) => repo.id === activeRepoId)) {
setRepoId(activeRepoId)
return
}
if (!repoId) {
setRepoId(repos[0].id)
}
}, [isOpen, repos, repoId, preselectedRepoId])
}, [isOpen, repos, repoId, preselectedRepoId, activeWorktreeRepoId, activeRepoId])
React.useEffect(() => {
if (!isOpen || !repoId || !suggestedName) return
const shouldApplySuggestion = !name.trim() || name === lastSuggestedNameRef.current
if (!shouldApplySuggestion) return
setName(suggestedName)
lastSuggestedNameRef.current = suggestedName
requestAnimationFrame(() => {
const input = nameInputRef.current
if (!input) return
input.focus()
input.select()
})
}, [isOpen, repoId, suggestedName, name])
// Safety guard: creating a worktree requires at least one repo.
React.useEffect(() => {
@ -172,6 +215,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Name</label>
<Input
ref={nameInputRef}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="feature/my-feature"
@ -235,3 +279,66 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
})
export default AddWorktreeDialog
function getSuggestedSpaceName(
repoId: string,
worktreesByRepo: Record<string, { path: string }[]>,
nestWorkspaces: boolean
): string {
if (!repoId) return SPACE_NAMES[0]
const usedNames = new Set<string>()
const repoWorktrees = worktreesByRepo[repoId] ?? []
for (const worktree of repoWorktrees) {
usedNames.add(normalizeSpaceName(lastPathSegment(worktree.path)))
}
if (!nestWorkspaces) {
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
usedNames.add(normalizeSpaceName(lastPathSegment(worktree.path)))
}
}
}
for (const candidate of SPACE_NAMES) {
if (!usedNames.has(normalizeSpaceName(candidate))) {
return candidate
}
}
let suffix = 2
while (true) {
for (const candidate of SPACE_NAMES) {
const numberedCandidate = `${candidate}-${suffix}`
if (!usedNames.has(normalizeSpaceName(numberedCandidate))) {
return numberedCandidate
}
}
suffix += 1
}
}
function lastPathSegment(path: string): string {
return path.replace(/\/+$/, '').split('/').pop() ?? path
}
function normalizeSpaceName(name: string): string {
return name.trim().toLowerCase()
}
function findRepoIdForWorktree(
worktreeId: string | null,
worktreesByRepo: Record<string, { id: string }[]>
): string | null {
if (!worktreeId) return null
for (const [repoId, worktrees] of Object.entries(worktreesByRepo)) {
if (worktrees.some((worktree) => worktree.id === worktreeId)) {
return repoId
}
}
return null
}

View file

@ -2,20 +2,25 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
)
}
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
ref={ref}
type={type}
data-slot="input"
className={cn(
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }