Polish host-aware Add Project flows

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 15:38:34 -07:00
co-authored by Orca
parent f15680a46e
commit 5a89cf0a13
25 changed files with 284 additions and 85 deletions
+24
View File
@@ -1720,6 +1720,30 @@ describe('repos:add + repos:clone', () => {
expect(existsSync(clonePath)).toBe(false)
})
it('reports the full fatal clone error when stderr includes progress fragments', async () => {
const destination = await createTempRoot()
const proc = createMockCloneProcess()
gitSpawnMock.mockReturnValueOnce(proc)
const clonePromise = handlers.get('repos:clone')!(null, {
url: 'https://example.com/orca.git',
destination
})
await waitForAssertion(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
proc.stderr.emit(
'data',
Buffer.from(
"Cloning into 'orca'...\rfatal: destination path 'orca' already exists and is not an empty directory.\r\nand the repository exists.\n"
)
)
proc.emit('close', 128, null)
await expect(clonePromise).rejects.toThrow(
"Clone failed: fatal: destination path 'orca' already exists and is not an empty directory."
)
})
it('removes an owned fresh clone target when git spawn emits an error', async () => {
const destination = await createTempRoot()
const clonePath = join(destination, 'orca')
+2 -2
View File
@@ -86,6 +86,7 @@ import {
getFolderWorkspacePathStatus,
getFolderWorkspacePathStatusForPath
} from '../project-groups/folder-workspace-path-status'
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
// Why: `method` answers "which entry point did the user take?", not "what did
// they add?" — so the IPC the renderer invoked IS the method. We never send
@@ -2109,8 +2110,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
} else if (code === 0) {
resolve()
} else {
const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error'
reject(new Error(`Clone failed: ${lastLine}`))
reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail)}`))
}
}
+2 -2
View File
@@ -25,6 +25,7 @@ import {
deriveValidatedClonePath,
getClonePathComparisonKey
} from '../git/repo-clone-path'
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
import { createHash, randomUUID } from 'crypto'
import { homedir } from 'os'
import { isAbsolute, join, resolve } from 'path'
@@ -7236,8 +7237,7 @@ export class OrcaRuntimeService {
} else if (code === 0) {
resolve()
} else {
const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error'
reject(new Error(`Clone failed: ${lastLine}`))
reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail)}`))
}
}
proc.on('error', (error) => {
+2 -2
View File
@@ -44,6 +44,7 @@ import {
removeSafeUntrackedDiscardTarget,
removeSafeUntrackedDiscardTargets
} from '../shared/git-discard-path-safety'
import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message'
const execFileAsync = promisify(execFile)
const MAX_GIT_BUFFER = 10 * 1024 * 1024
@@ -671,8 +672,7 @@ export class GitHandler {
resolve({ stdout, stderr })
return
}
const lastLine = stderr.trim().split('\n').pop() ?? 'unknown error'
reject(new Error(`Clone failed: ${lastLine}`))
reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderr)}`))
})
})
}
@@ -15,6 +15,7 @@ type CloneStepProps = {
disableDestinationPicker?: boolean
runtimeEnvironmentId?: string | null
sshTargetId?: string | null
cloneTargetLabel?: string | null
onUrlChange: (value: string) => void
onDestChange: (value: string) => void
onPickDestination: () => void
@@ -30,13 +31,15 @@ export function CloneStep({
disableDestinationPicker = false,
runtimeEnvironmentId,
sshTargetId,
cloneTargetLabel,
onUrlChange,
onDestChange,
onPickDestination,
onClone
}: CloneStepProps): React.JSX.Element {
const [browsingDestination, setBrowsingDestination] = useState(false)
const canBrowseRemoteDestination = Boolean(runtimeEnvironmentId || sshTargetId)
const isRemoteClone = Boolean(runtimeEnvironmentId || sshTargetId)
const canBrowseRemoteDestination = isRemoteClone
const canClone = !!cloneUrl.trim() && !!cloneDestination.trim() && !isCloning
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
@@ -96,10 +99,16 @@ export function CloneStep({
{translate('auto.components.sidebar.AddRepoSteps.c05f88a31f', 'Clone from URL')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.sidebar.AddRepoSteps.5b2ea674b1',
'Enter the Git URL and choose where to clone it.'
)}
{cloneTargetLabel
? translate(
'auto.components.sidebar.AddRepoSteps.cloneOnHostDescription',
'Enter the Git URL and choose where to clone it on {{value0}}.',
{ value0: cloneTargetLabel }
)
: translate(
'auto.components.sidebar.AddRepoSteps.5b2ea674b1',
'Enter the Git URL and choose where to clone it.'
)}
</DialogDescription>
</DialogHeader>
@@ -124,7 +133,7 @@ export function CloneStep({
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.AddRepoSteps.04a4c4e84a', 'Clone location')}
{translate('auto.components.sidebar.AddRepoSteps.cloneParentFolder', 'Parent folder')}
</label>
<div className="flex gap-2">
<Input
@@ -132,8 +141,10 @@ export function CloneStep({
onChange={(e) => onDestChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={translate(
'auto.components.sidebar.AddRepoSteps.2ce3f6edf8',
'/path/to/destination'
isRemoteClone
? 'auto.components.sidebar.AddRepoSteps.remoteCloneParentPlaceholder'
: 'auto.components.sidebar.AddRepoSteps.2ce3f6edf8',
isRemoteClone ? '/home/user/projects' : '/path/to/destination'
)}
className="h-8 text-xs flex-1"
disabled={isCloning}
@@ -33,6 +33,7 @@ type CreateStepProps = {
parentDefaultPending?: boolean
manualParentEntry?: boolean
runtimeEnvironmentId?: string | null
sshTargetId?: string | null
onNameChange: (value: string) => void
onParentChange: (value: string) => void
onKindChange: (kind: RepoKind) => void
@@ -52,6 +53,7 @@ export function CreateStep({
parentDefaultPending = false,
manualParentEntry = false,
runtimeEnvironmentId,
sshTargetId,
onNameChange,
onParentChange,
onKindChange,
@@ -112,6 +114,7 @@ export function CreateStep({
'auto.components.sidebar.AddRepoCreateStep.6ed14c0281',
'server folder not selected'
)
const isRemoteHost = Boolean(runtimeEnvironmentId || sshTargetId)
const summaryParent = useMemo(
() =>
@@ -119,12 +122,14 @@ export function CreateStep({
parent: createParent,
defaultParent,
runtimeEnvironmentId,
isRemoteHost,
missingLocationLabel,
missingServerLocationLabel
}),
[
createParent,
defaultParent,
isRemoteHost,
missingLocationLabel,
missingServerLocationLabel,
runtimeEnvironmentId
@@ -143,10 +148,11 @@ export function CreateStep({
const showRuntimeMissingParent =
runtimeEnvironmentId && !createParent.trim() && runtimeParentStatus !== 'checking'
if (browsingParent && runtimeEnvironmentId) {
if (browsingParent && (runtimeEnvironmentId || sshTargetId)) {
return (
<CreateProjectParentBrowser
runtimeEnvironmentId={runtimeEnvironmentId}
sshTargetId={sshTargetId}
createParent={createParent}
onParentChange={onParentChange}
onClose={() => setBrowsingParent(false)}
@@ -355,6 +361,7 @@ export function CreateStep({
isCreating={isCreating}
manualParentEntry={manualParentEntry}
runtimeEnvironmentId={runtimeEnvironmentId}
sshTargetId={sshTargetId}
onParentChange={onParentChange}
onPickParent={onPickParent}
onBrowseServer={() => setBrowsingParent(true)}
@@ -110,15 +110,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
}
)
const {
hostOptions,
selectedHostId,
selectedParsedHost,
selectedSshTargetId,
hostSelectorOpen,
setHostSelectorOpen,
handleSelectAddProjectHost
} = useAddRepoHostSelection({ isOpen: activeModal === 'add-repo', setStep })
const hostSelection = useAddRepoHostSelection({ isOpen: activeModal === 'add-repo', setStep })
const {
createName,
@@ -137,7 +129,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
fetchWorktrees,
closeModal,
(repoId) => completeGitRepoAdd(repoId, 'create_project'),
{ hostId: selectedHostId, sshTargetId: selectedSshTargetId }
{ hostId: hostSelection.selectedHostId, sshTargetId: hostSelection.selectedSshTargetId }
)
const {
@@ -151,6 +143,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
} = useCreateProjectDefaults({
step,
activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId,
sshTargetId: hostSelection.selectedSshTargetId,
createParent,
setCreateParent,
setCreateKind
@@ -171,7 +164,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
} = useAddRepoCloneFlow({
step,
activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId,
sshTargetId: selectedSshTargetId,
sshTargetId: hostSelection.selectedSshTargetId,
workspaceDir: settings?.workspaceDir,
fetchWorktrees,
onGitRepoReady: completeGitRepoAdd
@@ -257,14 +250,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
resetCreateState
])
// Why: reset state on close so reopening doesn't show stale step/repo.
useEffect(() => {
if (!isOpen) {
resetState()
}
}, [isOpen, resetState])
// Why: handleBack reuses resetState which already aborts clones and resets all fields.
const handleBack = useCallback(() => {
if (step === 'nested') {
trackNestedBackAction()
@@ -272,16 +263,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
resetState()
}, [resetState, step, trackNestedBackAction])
const hostSelector = (
<AddRepoHostSelector
hosts={hostOptions}
selectedHostId={selectedHostId}
open={hostSelectorOpen}
onOpenChange={setHostSelectorOpen}
onSelectHost={(hostId) => void handleSelectAddProjectHost(hostId)}
/>
)
return (
<Dialog
open={isOpen}
@@ -320,8 +301,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
isCloning={isCloning}
sshTargets={sshTargets}
selectedTargetId={selectedTargetId}
selectedSshTargetId={selectedSshTargetId}
lockSshTargetSelection={selectedParsedHost?.kind === 'ssh'}
selectedSshTargetId={hostSelection.selectedSshTargetId}
selectedHostLabel={
hostSelection.hostOptions.find((host) => host.id === hostSelection.selectedHostId)
?.label ?? hostSelection.selectedHostId
}
lockSshTargetSelection={hostSelection.selectedParsedHost?.kind === 'ssh'}
remotePath={remotePath}
remoteError={remoteError}
isAddingRemote={isAddingRemote}
@@ -334,16 +319,26 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
createKind={createKind}
createError={createError}
isCreating={isCreating}
hostSelector={hostSelector}
hostSelector={
<AddRepoHostSelector
hosts={hostSelection.hostOptions}
selectedHostId={hostSelection.selectedHostId}
open={hostSelection.hostSelectorOpen}
onOpenChange={hostSelection.setHostSelectorOpen}
onSelectHost={(hostId) => void hostSelection.handleSelectAddProjectHost(hostId)}
/>
}
showRemoteAction={false}
createDefaultParent={createDefaultParent}
createGitAvailability={createGitAvailability}
createRuntimeParentStatus={createRuntimeParentStatus}
createParentDefaultPending={createParentDefaultPending}
manualCreateParentEntry={isRuntimeEnvironmentActive || selectedParsedHost?.kind === 'ssh'}
manualCreateParentEntry={
isRuntimeEnvironmentActive || hostSelection.selectedParsedHost?.kind === 'ssh'
}
onBrowse={
selectedParsedHost?.kind === 'ssh'
? () => void handleOpenRemoteStep(selectedSshTargetId)
hostSelection.selectedParsedHost?.kind === 'ssh'
? () => void handleOpenRemoteStep(hostSelection.selectedSshTargetId)
: handleBrowse
}
onOpenCloneStep={() => {
@@ -137,11 +137,14 @@ describe('AddRepoDialogStepContent nested imports', () => {
const html = renderStepContent({
step: 'create',
manualCreateParentEntry: true,
selectedSshTargetId: 'openclaw-2',
activeRuntimeEnvironmentId: null
})
expect(html).toContain('Create a new project')
expect(html).toContain('placeholder="/home/user/projects"')
expect(html).toContain('aria-label="Browse server filesystem"')
expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*aria-label="Browse server filesystem"/)
expect(html).not.toContain('Choose parent folder')
})
@@ -159,10 +162,13 @@ describe('AddRepoDialogStepContent nested imports', () => {
it('offers SSH browsing for selected-host clone destinations', () => {
const html = renderStepContent({
step: 'clone',
selectedSshTargetId: 'openclaw-2'
selectedSshTargetId: 'openclaw-2',
selectedHostLabel: 'openclaw 2'
})
expect(html).toContain('Clone from URL')
expect(html).toContain('choose where to clone it on openclaw 2')
expect(html).toContain('Parent folder')
expect(html).toContain('aria-label="Browse server filesystem"')
expect(html).not.toContain('aria-label="Choose folder"')
})
@@ -30,6 +30,7 @@ type AddRepoDialogStepContentProps = {
sshTargets: (SshTarget & { state?: SshConnectionState })[]
selectedTargetId: string | null
selectedSshTargetId?: string | null
selectedHostLabel?: string | null
lockSshTargetSelection?: boolean
remotePath: string
remoteError: string | null
@@ -98,6 +99,7 @@ export function AddRepoDialogStepContent({
sshTargets,
selectedTargetId,
selectedSshTargetId,
selectedHostLabel,
lockSshTargetSelection = false,
remotePath,
remoteError,
@@ -213,6 +215,9 @@ export function AddRepoDialogStepContent({
disableDestinationPicker={isRuntimeEnvironmentActive}
runtimeEnvironmentId={activeRuntimeEnvironmentId}
sshTargetId={selectedSshTargetId}
cloneTargetLabel={
isRuntimeEnvironmentActive || selectedSshTargetId ? selectedHostLabel : null
}
onUrlChange={onCloneUrlChange}
onDestChange={onCloneDestinationChange}
onPickDestination={onPickCloneDestination}
@@ -251,6 +256,7 @@ export function AddRepoDialogStepContent({
parentDefaultPending={createParentDefaultPending}
manualParentEntry={manualCreateParentEntry}
runtimeEnvironmentId={activeRuntimeEnvironmentId}
sshTargetId={selectedSshTargetId}
onNameChange={onCreateNameChange}
onParentChange={onCreateParentChange}
onKindChange={onCreateKindChange}
@@ -6,6 +6,7 @@ import type { NestedRepoScanResult } from '../../../../shared/types'
import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types'
import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-repo-telemetry'
import { translate } from '@/i18n/i18n'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
// ── Remote project hook ─────────────────────────────────────────────
@@ -210,7 +211,7 @@ export function useRemoteRepo(
}
await onGitRepoReady?.(repo.id)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = extractIpcErrorMessage(err, String(err))
if (message.includes('Not a valid git repository')) {
// Why: match the local add-project flow — show confirmation dialog so
// users understand git features will be unavailable, rather than
@@ -7,7 +7,8 @@ import { RemoteFileBrowser } from './RemoteFileBrowser'
import { translate } from '@/i18n/i18n'
type CreateProjectParentBrowserProps = {
runtimeEnvironmentId: string
runtimeEnvironmentId?: string | null
sshTargetId?: string | null
createParent: string
onParentChange: (value: string) => void
onClose: () => void
@@ -15,6 +16,7 @@ type CreateProjectParentBrowserProps = {
export function CreateProjectParentBrowser({
runtimeEnvironmentId,
sshTargetId,
createParent,
onParentChange,
onClose
@@ -35,15 +37,27 @@ export function CreateProjectParentBrowser({
)}
</DialogDescription>
</DialogHeader>
<RemoteFileBrowser
runtimeEnvironmentId={runtimeEnvironmentId}
initialPath={createParent || '~'}
onSelect={(path) => {
onParentChange(path)
onClose()
}}
onCancel={onClose}
/>
{sshTargetId ? (
<RemoteFileBrowser
targetId={sshTargetId}
initialPath={createParent || '~'}
onSelect={(path) => {
onParentChange(path)
onClose()
}}
onCancel={onClose}
/>
) : (
<RemoteFileBrowser
runtimeEnvironmentId={runtimeEnvironmentId as string}
initialPath={createParent || '~'}
onSelect={(path) => {
onParentChange(path)
onClose()
}}
onCancel={onClose}
/>
)}
</>
)
}
@@ -53,6 +67,7 @@ type CreateProjectLocationFieldProps = {
isCreating: boolean
manualParentEntry: boolean
runtimeEnvironmentId?: string | null
sshTargetId?: string | null
onParentChange: (value: string) => void
onPickParent: () => void
onBrowseServer: () => void
@@ -63,6 +78,7 @@ export function CreateProjectLocationField({
isCreating,
manualParentEntry,
runtimeEnvironmentId,
sshTargetId,
onParentChange,
onPickParent,
onBrowseServer
@@ -94,7 +110,7 @@ export function CreateProjectLocationField({
size="icon"
className="h-11 w-11 shrink-0"
onClick={onBrowseServer}
disabled={isCreating || !runtimeEnvironmentId}
disabled={isCreating || (!runtimeEnvironmentId && !sshTargetId)}
aria-label={translate(
'auto.components.sidebar.CreateProjectLocationField.f520f83a97',
'Browse server filesystem'
@@ -86,5 +86,19 @@ describe('create project defaults', () => {
runtimeEnvironmentId: 'env-1'
})
).toBe('server folder not selected')
expect(
formatCreateProjectParentSummary({
parent: '/Users/alice/orca/projects',
defaultParent: '/Users/alice/orca/projects',
isRemoteHost: true
})
).toBe('/Users/alice/orca/projects')
expect(
formatCreateProjectParentSummary({
parent: '',
defaultParent: '',
isRemoteHost: true
})
).toBe('server folder not selected')
})
})
@@ -68,20 +68,22 @@ export function formatCreateProjectParentSummary({
parent,
defaultParent,
runtimeEnvironmentId,
isRemoteHost,
missingLocationLabel = 'location not selected',
missingServerLocationLabel = 'server folder not selected'
}: {
parent: string
defaultParent: string
runtimeEnvironmentId?: string | null
isRemoteHost?: boolean
missingLocationLabel?: string
missingServerLocationLabel?: string
}): string {
const trimmedParent = parent.trim()
if (!trimmedParent) {
return runtimeEnvironmentId ? missingServerLocationLabel : missingLocationLabel
return runtimeEnvironmentId || isRemoteHost ? missingServerLocationLabel : missingLocationLabel
}
if (defaultParent && trimmedParent === defaultParent && !runtimeEnvironmentId) {
if (defaultParent && trimmedParent === defaultParent && !runtimeEnvironmentId && !isRemoteHost) {
return '~/orca/projects'
}
return trimmedParent
@@ -151,4 +151,27 @@ describe('useAddRepoCloneFlow', () => {
expect(result.cloneDestination).toBe('')
expect(mocks.stateSetters[1]).not.toHaveBeenCalledWith('/private/tmp/orca-setup-e2e.hOWO1f')
})
it('strips Electron IPC wrappers from clone errors', async () => {
mocks.cloneRemote.mockRejectedValue(
new Error(
"Error invoking remote method 'repos:cloneRemote': Error: Clone failed: fatal: destination path 'orca' already exists and is not an empty directory."
)
)
const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow')
const result = useAddRepoCloneFlow({
step: 'clone',
activeRuntimeEnvironmentId: null,
sshTargetId: 'ssh-1',
workspaceDir: '/local/workspace',
fetchWorktrees: mocks.fetchWorktrees,
onGitRepoReady: mocks.onGitRepoReady
})
await result.handleClone()
expect(mocks.stateSetters[3]).toHaveBeenCalledWith(
"Clone failed: fatal: destination path 'orca' already exists and is not an empty directory."
)
})
})
@@ -7,6 +7,7 @@ import type { Repo } from '../../../../shared/types'
import { getCloneDestinationAutoFill } from './clone-defaults'
import type { AddRepoDialogStep } from './add-repo-dialog-types'
import { translate } from '@/i18n/i18n'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
export function useAddRepoCloneFlow({
step,
@@ -166,7 +167,7 @@ export function useAddRepoCloneFlow({
if (gen !== cloneGenRef.current || requestHostToken !== hostTokenRef.current) {
return
}
const message = err instanceof Error ? err.message : String(err)
const message = extractIpcErrorMessage(err, String(err))
setCloneError(message)
} finally {
if (gen === cloneGenRef.current && requestHostToken === hostTokenRef.current) {
@@ -248,6 +248,20 @@ describe('useCreateProjectDefaults', () => {
expect(setCreateParent).not.toHaveBeenCalled()
})
it('does not use client defaults or Git probing for SSH targets', async () => {
mocks.isGitAvailable.mockResolvedValue(true)
const { setCreateParent, setCreateKind } = useHarness({ sshTargetId: 'ssh-1' })
await flushAsync()
expect(setCreateParent).not.toHaveBeenCalled()
expect(setCreateKind).not.toHaveBeenCalled()
expect(mocks.getDefaultCreateProjectParent).not.toHaveBeenCalled()
expect(mocks.isGitAvailable).not.toHaveBeenCalled()
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unknown')
})
it('does nothing outside the create step', async () => {
const { setCreateParent, setCreateKind } = useHarness({ step: 'add' })
await flushAsync()
@@ -18,12 +18,12 @@ export type CreateRuntimeParentStatus = 'idle' | 'checking' | 'failed'
type AutoFilledCreateParent = {
parent: string
runtimeEnvironmentId: string | null
targetKey: string
}
type CreateParentProvenance = {
parent: string
runtimeEnvironmentId: string | null
targetKey: string
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
@@ -50,12 +50,14 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
export function useCreateProjectDefaults({
step,
activeRuntimeEnvironmentId,
sshTargetId,
createParent,
setCreateParent,
setCreateKind
}: {
step: AddRepoDialogStep
activeRuntimeEnvironmentId: string | null | undefined
sshTargetId?: string | null | undefined
createParent: string
setCreateParent: (value: string) => void
setCreateKind: (kind: RepoKind) => void
@@ -80,6 +82,12 @@ export function useCreateProjectDefaults({
const createParentDefaultGenRef = useRef(0)
const createGitProbeGenRef = useRef(0)
const activeCreateParentRuntimeEnvironmentId = activeRuntimeEnvironmentId?.trim() || null
const activeCreateParentSshTargetId = sshTargetId?.trim() || null
const activeCreateParentTargetKey = activeCreateParentRuntimeEnvironmentId
? `runtime:${activeCreateParentRuntimeEnvironmentId}`
: activeCreateParentSshTargetId
? `ssh:${activeCreateParentSshTargetId}`
: 'local'
const canReplaceCreateParentDefault = useCallback((parent: string): boolean => {
if (createParentTouchedRef.current) {
@@ -108,11 +116,11 @@ export function useCreateProjectDefaults({
autoFilledCreateParentRef.current = null
createParentProvenanceRef.current = {
parent: (value ?? createParent).trim(),
runtimeEnvironmentId: activeCreateParentRuntimeEnvironmentId
targetKey: activeCreateParentTargetKey
}
createParentTouchedRef.current = true
},
[activeCreateParentRuntimeEnvironmentId, createParent]
[activeCreateParentTargetKey, createParent]
)
const markCreateKindTouched = useCallback(() => {
createKindTouchedRef.current = true
@@ -123,21 +131,19 @@ export function useCreateProjectDefaults({
!createParentTouchedRef.current &&
Boolean(createParent.trim()) &&
autoFilledCreateParentRef.current?.parent === createParent.trim() &&
autoFilledCreateParentRef.current.runtimeEnvironmentId !==
activeCreateParentRuntimeEnvironmentId
autoFilledCreateParentRef.current.targetKey !== activeCreateParentTargetKey
const createParentTargetPending =
step === 'create' &&
Boolean(createParent.trim()) &&
createParentProvenanceRef.current?.parent === createParent.trim() &&
createParentProvenanceRef.current.runtimeEnvironmentId !==
activeCreateParentRuntimeEnvironmentId
createParentProvenanceRef.current.targetKey !== activeCreateParentTargetKey
const createParentPending = createParentDefaultPending || createParentTargetPending
useEffect(() => {
if (step !== 'create') {
return
}
if (activeCreateParentRuntimeEnvironmentId) {
if (activeCreateParentRuntimeEnvironmentId || activeCreateParentSshTargetId) {
return
}
// Why: invalidate any in-flight runtime parent probe once local mode owns the default.
@@ -147,7 +153,7 @@ export function useCreateProjectDefaults({
}
if (
createParent.trim() &&
autoFilledCreateParentRef.current?.runtimeEnvironmentId !== null &&
autoFilledCreateParentRef.current?.targetKey !== 'local' &&
autoFilledCreateParentRef.current?.parent === createParent.trim()
) {
setCreateDefaultParent('')
@@ -155,7 +161,7 @@ export function useCreateProjectDefaults({
return
}
if (
autoFilledCreateParentRef.current?.runtimeEnvironmentId === null &&
autoFilledCreateParentRef.current?.targetKey === 'local' &&
autoFilledCreateParentRef.current.parent === createParent.trim()
) {
return
@@ -173,8 +179,8 @@ export function useCreateProjectDefaults({
}
setCreateDefaultParent(parent)
createStepAutoFilledRef.current = true
autoFilledCreateParentRef.current = { parent, runtimeEnvironmentId: null }
createParentProvenanceRef.current = { parent, runtimeEnvironmentId: null }
autoFilledCreateParentRef.current = { parent, targetKey: 'local' }
createParentProvenanceRef.current = { parent, targetKey: 'local' }
setCreateParent(parent)
})
.catch(() => {
@@ -183,6 +189,7 @@ export function useCreateProjectDefaults({
}, [
activeRuntimeEnvironmentId,
activeCreateParentRuntimeEnvironmentId,
activeCreateParentSshTargetId,
canReplaceCreateParentDefault,
createParent,
setCreateParent,
@@ -194,7 +201,7 @@ export function useCreateProjectDefaults({
return
}
const runtimeEnvironmentId = activeCreateParentRuntimeEnvironmentId
if (!runtimeEnvironmentId) {
if (!runtimeEnvironmentId || activeCreateParentSshTargetId) {
setCreateRuntimeParentStatus('idle')
return
}
@@ -204,7 +211,7 @@ export function useCreateProjectDefaults({
}
if (
createParent.trim() &&
autoFilledCreateParentRef.current?.runtimeEnvironmentId !== runtimeEnvironmentId &&
autoFilledCreateParentRef.current?.targetKey !== `runtime:${runtimeEnvironmentId}` &&
autoFilledCreateParentRef.current?.parent === createParent.trim()
) {
setCreateDefaultParent('')
@@ -213,7 +220,7 @@ export function useCreateProjectDefaults({
return
}
if (
autoFilledCreateParentRef.current?.runtimeEnvironmentId === runtimeEnvironmentId &&
autoFilledCreateParentRef.current?.targetKey === `runtime:${runtimeEnvironmentId}` &&
autoFilledCreateParentRef.current.parent === createParent.trim()
) {
setCreateRuntimeParentStatus('idle')
@@ -236,8 +243,8 @@ export function useCreateProjectDefaults({
}
const parent = getDefaultCreateProjectParent(result.resolvedPath)
createStepAutoFilledRef.current = true
autoFilledCreateParentRef.current = { parent, runtimeEnvironmentId }
createParentProvenanceRef.current = { parent, runtimeEnvironmentId }
autoFilledCreateParentRef.current = { parent, targetKey: `runtime:${runtimeEnvironmentId}` }
createParentProvenanceRef.current = { parent, targetKey: `runtime:${runtimeEnvironmentId}` }
setCreateDefaultParent(parent)
setCreateParent(parent)
setCreateRuntimeParentStatus('idle')
@@ -251,6 +258,7 @@ export function useCreateProjectDefaults({
}, [
activeRuntimeEnvironmentId,
activeCreateParentRuntimeEnvironmentId,
activeCreateParentSshTargetId,
canReplaceCreateParentDefault,
createParent,
setCreateParent,
@@ -263,6 +271,12 @@ export function useCreateProjectDefaults({
}
const runtimeEnvironmentId = activeRuntimeEnvironmentId?.trim()
const gen = ++createGitProbeGenRef.current
if (activeCreateParentSshTargetId) {
// Why: SSH creation happens through the relay; probing client Git would
// make the selected host look healthier or less healthy than it is.
setCreateGitAvailability('unknown')
return
}
setCreateGitAvailability('checking')
const probe = runtimeEnvironmentId
? callRuntimeRpc<{ available: boolean }>(
@@ -293,7 +307,7 @@ export function useCreateProjectDefaults({
}
setCreateGitAvailability('unknown')
})
}, [activeRuntimeEnvironmentId, setCreateKind, step])
}, [activeRuntimeEnvironmentId, activeCreateParentSshTargetId, setCreateKind, step])
return {
createDefaultParent,
@@ -11,6 +11,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { Repo } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import type { RepoKind } from './create-project-defaults'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
export function useCreateRepo(
fetchWorktrees: (
@@ -200,7 +201,7 @@ export function useCreateRepo(
) {
return
}
setCreateError(err instanceof Error ? err.message : String(err))
setCreateError(extractIpcErrorMessage(err, String(err)))
} finally {
// Why: only clear the loading state if this invocation is still current;
// a superseded create must not flip the flag back off for a new flow.
+3 -1
View File
@@ -3168,7 +3168,9 @@
"df8b0e6c22": "Remote project added",
"3e64e8a70d": "Connection failed",
"32a7256d85": "Clone",
"69f5b5380d": "Cloning..."
"69f5b5380d": "Cloning...",
"cloneOnHostDescription": "Enter the Git URL and choose where to clone it on {{value0}}.",
"cloneParentFolder": "Parent folder"
},
"AutoRenameFailedDialog": {
"aed1623b1e": "Close",
+3 -1
View File
@@ -3162,7 +3162,9 @@
"df8b0e6c22": "Proyecto remoto agregado",
"3e64e8a70d": "La conexión falló",
"32a7256d85": "Clon",
"69f5b5380d": "Clonación..."
"69f5b5380d": "Clonación...",
"cloneOnHostDescription": "Enter the Git URL and choose where to clone it on {{value0}}.",
"cloneParentFolder": "Parent folder"
},
"AutoRenameFailedDialog": {
"aed1623b1e": "Cerca",
+3 -1
View File
@@ -3149,7 +3149,9 @@
"df8b0e6c22": "リモートプロジェクトが追加されました",
"3e64e8a70d": "接続に失敗しました",
"32a7256d85": "クローン",
"69f5b5380d": "クローン作成中..."
"69f5b5380d": "クローン作成中...",
"cloneOnHostDescription": "Enter the Git URL and choose where to clone it on {{value0}}.",
"cloneParentFolder": "Parent folder"
},
"AutoRenameFailedDialog": {
"aed1623b1e": "閉じる",
+3 -1
View File
@@ -3149,7 +3149,9 @@
"df8b0e6c22": "원격 프로젝트가 추가되었습니다.",
"3e64e8a70d": "연결 실패",
"32a7256d85": "클론",
"69f5b5380d": "복제 중..."
"69f5b5380d": "복제 중...",
"cloneOnHostDescription": "Enter the Git URL and choose where to clone it on {{value0}}.",
"cloneParentFolder": "Parent folder"
},
"AutoRenameFailedDialog": {
"aed1623b1e": "닫기",
+3 -1
View File
@@ -3149,7 +3149,9 @@
"df8b0e6c22": "添加了远程项目",
"3e64e8a70d": "连接失败",
"32a7256d85": "克隆",
"69f5b5380d": "克隆..."
"69f5b5380d": "克隆...",
"cloneOnHostDescription": "Enter the Git URL and choose where to clone it on {{value0}}.",
"cloneParentFolder": "Parent folder"
},
"AutoRenameFailedDialog": {
"aed1623b1e": "关闭",
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { getGitCloneFailureMessage } from './git-clone-failure-message'
describe('getGitCloneFailureMessage', () => {
it('keeps the full fatal destination path message after progress output', () => {
expect(
getGitCloneFailureMessage(
[
'Cloning into \u001b[32morca\u001b[0m...\r',
"fatal: destination path 'orca' already exists and is not an empty directory.\n"
].join('')
)
).toBe("fatal: destination path 'orca' already exists and is not an empty directory.")
})
it('prefers the last fatal line over a trailing fragment', () => {
expect(
getGitCloneFailureMessage(
"fatal: destination path 'orca' already exists and is not an empty directory.\r\nand the repository exists.\n"
)
).toBe("fatal: destination path 'orca' already exists and is not an empty directory.")
})
it('falls back to the last non-empty line', () => {
expect(getGitCloneFailureMessage('warning: retrying\nnetwork vanished\n')).toBe(
'network vanished'
)
})
})
+25
View File
@@ -0,0 +1,25 @@
export function getGitCloneFailureMessage(stderr: string): string {
const lines = stderr
.replace(/\r/g, '\n')
.split('\n')
.map((line) => stripAnsi(line).trim())
.filter(Boolean)
for (let index = lines.length - 1; index >= 0; index--) {
const line = lines[index]
const fatalIndex = line.indexOf('fatal:')
if (fatalIndex !== -1) {
return line.slice(fatalIndex)
}
const errorIndex = line.indexOf('error:')
if (errorIndex !== -1) {
return line.slice(errorIndex)
}
}
return lines.at(-1) ?? 'unknown error'
}
function stripAnsi(value: string): string {
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
}