mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
auto select wt
This commit is contained in:
parent
f595b7d473
commit
4d460a6ed0
4 changed files with 157 additions and 22 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in a new issue