diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 59dbbb5064e..8f1bdf0a392 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -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') diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index e902b7c6204..eb7205e3218 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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)}`)) } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 15bfe7efb35..fb1c4075466 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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) => { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index ae4cf5784fa..74883a6aa34 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -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)}`)) }) }) } diff --git a/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx b/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx index fbd7a5b1619..4d78029c304 100644 --- a/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx @@ -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): void => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { @@ -96,10 +99,16 @@ export function CloneStep({ {translate('auto.components.sidebar.AddRepoSteps.c05f88a31f', 'Clone from URL')} - {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.' + )} @@ -124,7 +133,7 @@ export function CloneStep({
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} diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index fe4b853f31a..2533f917226 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -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 ( setBrowsingParent(false)} @@ -355,6 +361,7 @@ export function CreateStep({ isCreating={isCreating} manualParentEntry={manualParentEntry} runtimeEnvironmentId={runtimeEnvironmentId} + sshTargetId={sshTargetId} onParentChange={onParentChange} onPickParent={onPickParent} onBrowseServer={() => setBrowsingParent(true)} diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 5701b688ee6..c46d1983f3e 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -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 = ( - void handleSelectAddProjectHost(hostId)} - /> - ) - return ( 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={ + 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={() => { diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index f811eb38ca1..0eb7d4de438 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -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(/]*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"') }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index f33447a43f9..aa9bf1fcc90 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -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} diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index 0c5b6de03ed..f5947a642a3 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx b/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx index 9b0ae00c3f9..2b8f2d8e053 100644 --- a/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx +++ b/src/renderer/src/components/sidebar/CreateProjectLocationField.tsx @@ -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({ )} - { - onParentChange(path) - onClose() - }} - onCancel={onClose} - /> + {sshTargetId ? ( + { + onParentChange(path) + onClose() + }} + onCancel={onClose} + /> + ) : ( + { + 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' diff --git a/src/renderer/src/components/sidebar/create-project-defaults.test.ts b/src/renderer/src/components/sidebar/create-project-defaults.test.ts index 4691b605081..5a89c457e01 100644 --- a/src/renderer/src/components/sidebar/create-project-defaults.test.ts +++ b/src/renderer/src/components/sidebar/create-project-defaults.test.ts @@ -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') }) }) diff --git a/src/renderer/src/components/sidebar/create-project-defaults.ts b/src/renderer/src/components/sidebar/create-project-defaults.ts index e5df269a967..702fde5e7b5 100644 --- a/src/renderer/src/components/sidebar/create-project-defaults.ts +++ b/src/renderer/src/components/sidebar/create-project-defaults.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts index 5d12cadbaaa..133ef15976c 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts @@ -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." + ) + }) }) diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts index 9329832e586..4c21a90c751 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts @@ -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) { diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts index 3508458daa4..aa81789d7e9 100644 --- a/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts @@ -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() diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts index 9c94cba1629..e1df52b084b 100644 --- a/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts @@ -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(promise: Promise, timeoutMs: number): Promise { @@ -50,12 +50,14 @@ function withTimeout(promise: Promise, timeoutMs: number): Promise { 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, diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index 89d5d80deae..e402d41dc30 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -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. diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 03bcaf066e6..24bf11caaa3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 66a37b736ef..0585de5064f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8e93ba57721..cb3e2fdce36 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "閉じる", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1dce26bb1fb..40b51080b8f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "닫기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index be9feecfb1a..f4e69b391a3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "关闭", diff --git a/src/shared/git-clone-failure-message.test.ts b/src/shared/git-clone-failure-message.test.ts new file mode 100644 index 00000000000..702b40e0c97 --- /dev/null +++ b/src/shared/git-clone-failure-message.test.ts @@ -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' + ) + }) +}) diff --git a/src/shared/git-clone-failure-message.ts b/src/shared/git-clone-failure-message.ts new file mode 100644 index 00000000000..df4e64940b6 --- /dev/null +++ b/src/shared/git-clone-failure-message.ts @@ -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'), '') +}