mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
Polish host-aware Add Project flows
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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)}`))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "閉じる",
|
||||
|
||||
@@ -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": "닫기",
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'), '')
|
||||
}
|
||||
Reference in New Issue
Block a user