diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 8f1bdf0a392..7e6dbd2ec6f 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -1024,7 +1024,7 @@ describe('repos:addRemote', () => { expect(result).toBe(updated) }) - it('cleans up a fresh SSH clone target after git clone fails', async () => { + it('does not delete a fresh SSH clone target after git clone fails', async () => { mockGitProvider.clone.mockRejectedValueOnce(new Error('repository not found')) mockFilesystemProvider.stat.mockRejectedValueOnce(new Error('not found')) @@ -1036,7 +1036,54 @@ describe('repos:addRemote', () => { }) ).rejects.toThrow('repository not found') - expect(mockFilesystemProvider.deletePath).toHaveBeenCalledWith('/home/user/orca', true) + expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled() + }) + + it('rejects concurrent SSH clones to the same destination', async () => { + let releaseClone!: () => void + mockGitProvider.clone.mockImplementationOnce( + async () => + new Promise<{ stdout: string; stderr: string }>((resolve) => { + releaseClone = () => resolve({ stdout: '', stderr: '' }) + }) + ) + + const firstClone = handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1)) + + await expect( + handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + ).rejects.toThrow('A clone is already in progress for this SSH destination') + + releaseClone() + await firstClone + }) + + it('resolves SSH clone destinations under home before validating the path', async () => { + mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' }) + + await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '~/projects' + }) + + expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', { + path: '~/projects' + }) + expect(mockGitProvider.clone).toHaveBeenCalledWith( + ['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'], + '/home/ubuntu/projects', + expect.any(Object) + ) }) it('does not clean up a pre-existing SSH clone target after git clone fails', async () => { @@ -1117,6 +1164,25 @@ describe('repos:addRemote', () => { expect(result).toHaveProperty('repo.connectionId', 'conn-1') }) + it('resolves SSH create parents under home before validating the path', async () => { + mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' }) + + const result = await handlers.get('repos:createRemote')!(null, { + connectionId: 'conn-1', + parentPath: '~/projects', + name: 'created', + kind: 'folder' + }) + + expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', { + path: '~/projects' + }) + expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith( + '/home/ubuntu/projects/created' + ) + expect(result).toHaveProperty('repo.path', '/home/ubuntu/projects/created') + }) + it('creates a new folder project on an SSH target without git init', async () => { const result = await handlers.get('repos:createRemote')!(null, { connectionId: 'conn-1', @@ -1374,6 +1440,31 @@ describe('repos:addRemote', () => { expect(result).toHaveProperty('repo.path', '/home/ubuntu/subdir') }) + it('returns an existing SSH repo when a selected subdirectory resolves to the repo root', async () => { + const existing = { + id: 'existing-id', + path: '/home/user/orca', + connectionId: 'conn-1', + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1000, + kind: 'git' + } + mockStore.getRepos.mockReturnValue([existing]) + mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ + isRepo: true, + rootPath: '/home/user/orca' + }) + + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/orca/src' + }) + + expect(result).toEqual({ repo: existing }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('ignores SSH target label when custom displayName is provided', async () => { mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu' }) mockStore.getSshTarget.mockReturnValueOnce({ diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index eb7205e3218..c96bb713656 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -222,28 +222,16 @@ async function addRemoteRepoFromPath( } let repoKind: 'git' | 'folder' = args.kind ?? 'git' - let resolvedPath = args.remotePath - - // Why: `~` is a shell expansion that Node's fs APIs don't understand. - // Resolve tilde paths to absolute paths via the relay before storing, - // so all downstream fs operations (readDir, stat, etc.) work correctly. - if (resolvedPath === '~' || resolvedPath === '~/' || resolvedPath.startsWith('~/')) { - const mux = getActiveMultiplexer(args.connectionId) - if (mux) { - try { - const result = (await mux.request('session.resolveHome', { - path: resolvedPath - })) as { resolvedPath: string } - resolvedPath = result.resolvedPath - } catch { - // Relay may not support resolveHome yet — fall through to raw path - } - } - } + let resolvedPath = await resolveRemoteHomePath(args.connectionId, args.remotePath) const existing = store .getRepos() - .find((r) => r.connectionId === args.connectionId && r.path === resolvedPath) + .find( + (repo) => + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === + normalizeRuntimePathForComparison(resolvedPath) + ) if (existing) { return { repo: existing, alreadyExisted: true } } @@ -267,6 +255,18 @@ async function addRemoteRepoFromPath( } } + const existingAfterRootResolve = store + .getRepos() + .find( + (repo) => + repo.connectionId === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === + normalizeRuntimePathForComparison(resolvedPath) + ) + if (existingAfterRootResolve) { + return { repo: existingAfterRootResolve, alreadyExisted: true } + } + const folderName = getRemoteRepoFolderName(resolvedPath) let displayName = args.displayName || folderName if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) { @@ -333,7 +333,7 @@ async function cloneRemoteRepo( if (!host) { throw new Error('SSH host platform is unavailable. Reconnect the SSH target before cloning.') } - const trimmedDestination = args.destination.trim() + const trimmedDestination = await resolveRemoteHomePath(args.connectionId, args.destination.trim()) if (!isRuntimePathAbsolute(trimmedDestination, host.pathFlavor)) { throw new Error('Clone destination must be an absolute path on the SSH host') } @@ -354,14 +354,10 @@ async function cloneRemoteRepo( return existing } - const fsProvider = getSshFilesystemProvider(args.connectionId) - const canCleanupCloneTarget = - !existing && - !!fsProvider && - !(await fsProvider - .stat(clonePath) - .then(() => true) - .catch(() => false)) + const remoteCloneKey = `${args.connectionId}:${clonePathKey}` + if (remoteCloneInFlightByPath.has(remoteCloneKey)) { + throw new Error('A clone is already in progress for this SSH destination') + } const controller = new AbortController() const metadata: ActiveRemoteCloneMetadata = { connectionId: args.connectionId, @@ -369,6 +365,7 @@ async function cloneRemoteRepo( controller } activeRemoteClone = metadata + remoteCloneInFlightByPath.add(remoteCloneKey) try { // Why: the SSH relay exposes argv-based git execution, not a shell. Use // the repo folder name as the target so git creates it inside the chosen @@ -387,9 +384,6 @@ async function cloneRemoteRepo( } ) } catch (err) { - if (canCleanupCloneTarget) { - await fsProvider?.deletePath(clonePath, true).catch(() => undefined) - } if (controller.signal.aborted) { throw new Error('Clone aborted') } @@ -398,6 +392,7 @@ async function cloneRemoteRepo( if (activeRemoteClone === metadata) { activeRemoteClone = null } + remoteCloneInFlightByPath.delete(remoteCloneKey) } if (existing && isFolderRepo(existing)) { const updated = store.updateRepo(existing.id, { @@ -435,7 +430,7 @@ async function createRemoteRepo( } ): Promise<{ repo: Repo } | { error: string }> { const name = args.name?.trim() ?? '' - const parentPath = args.parentPath?.trim() ?? '' + const parentPath = await resolveRemoteHomePath(args.connectionId, args.parentPath?.trim() ?? '') const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git' if (!name) { return { error: 'Name cannot be empty' } @@ -564,6 +559,24 @@ async function createRemoteRepo( return { repo: result.repo } } +async function resolveRemoteHomePath(connectionId: string, path: string): Promise { + if (path !== '~' && path !== '~/' && !path.startsWith('~/')) { + return path + } + const mux = getActiveMultiplexer(connectionId) + if (!mux) { + return path + } + try { + const result = (await mux.request('session.resolveHome', { path })) as { resolvedPath: string } + return result.resolvedPath + } catch { + // Why: older relays may not support this yet; callers will surface the + // original path validation error instead of failing during resolution. + return path + } +} + type ActiveCloneMetadata = { path: string pathKey: string @@ -590,6 +603,7 @@ let nextCloneGeneration = 1 const latestCloneGenerationByPath = new Map() const pendingAbortCleanupByPath = new Map>() const cloneInFlightByPath = new Map>() +const remoteCloneInFlightByPath = new Set() const activeNestedRepoScans = new Map() type CompletedNestedRepoScan = { scan: NestedRepoScanResult diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 1b429b25454..289513ffc3f 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -123,6 +123,18 @@ describe('SshGitProvider', () => { expect(unsubscribe).toHaveBeenCalledTimes(1) }) + it('reports an actionable reconnect message when the relay does not support cloning', async () => { + const methodNotFound = new Error('Method not found: git.clone') as Error & { code?: number } + methodNotFound.code = -32601 + mux.request.mockRejectedValueOnce(methodNotFound) + + await expect( + provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user') + ).rejects.toThrow( + 'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' + ) + }) + it('getHistory sends git.history request', async () => { const historyResult = { items: [], diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 4a1751f9a4d..acbbf3a9be1 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -595,6 +595,13 @@ export class SshGitProvider implements IGitProvider { stdout: string stderr: string } + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' + ) + } + throw error } finally { unsubscribe?.() } diff --git a/src/relay/git-exec-validator.test.ts b/src/relay/git-exec-validator.test.ts index d221351a5b4..529371c72a4 100644 --- a/src/relay/git-exec-validator.test.ts +++ b/src/relay/git-exec-validator.test.ts @@ -54,7 +54,6 @@ describe('validateGitExecArgs', () => { it.each([ 'push', 'pull', - 'commit', 'checkout', 'reset', 'rebase', @@ -226,4 +225,22 @@ describe('validateGitExecArgs', () => { expectBlocked(args, 'git clone') }) }) + + describe('git init and empty commit', () => { + it('allows only the SSH create-project init and empty commit shapes', () => { + expectAllowed(['init']) + expectAllowed(['commit', '--allow-empty', '-m', 'Initial commit']) + }) + + it.each([ + [['init', '--bare']], + [['init', '/tmp/other']], + [['commit']], + [['commit', '-am', 'message']], + [['commit', '--allow-empty']], + [['commit', '--allow-empty', '-m', '']] + ])('rejects unsafe create-project write args %j', (args) => { + expectBlocked(args, 'via exec is restricted') + }) + }) }) diff --git a/src/relay/git-exec-validator.ts b/src/relay/git-exec-validator.ts index 8e5f10b2986..546f92afc93 100644 --- a/src/relay/git-exec-validator.ts +++ b/src/relay/git-exec-validator.ts @@ -5,8 +5,9 @@ * Extracted from git-handler-ops.ts to keep both files under the limit. */ -// Why: only read-only git subcommands are allowed via exec. config is restricted -// to read-only flags; branch rejects destructive flags; fetch/worktree removed. +// Why: only read-only git subcommands are allowed via exec, except for the +// exact init/empty-commit shapes used by SSH Create Project after the parent +// directory has already been validated by main. const ALLOWED_GIT_SUBCOMMANDS = new Set([ 'rev-parse', 'branch', @@ -19,6 +20,8 @@ const ALLOWED_GIT_SUBCOMMANDS = new Set([ 'diff', 'ls-files', 'clone', + 'init', + 'commit', 'for-each-ref', 'check-ref-format', 'config' @@ -102,6 +105,18 @@ function validateCloneArgs(args: string[]): void { } } +function validateInitArgs(args: string[]): void { + if (args.length !== 1) { + throw new Error('git init via exec is restricted to init with no arguments') + } +} + +function validateCommitArgs(args: string[]): void { + if (args.length !== 4 || args[1] !== '--allow-empty' || args[2] !== '-m' || !args[3]) { + throw new Error('git commit via exec is restricted to commit --allow-empty -m ') + } +} + // Why: git accepts --flag=value compound syntax (e.g. --file=/etc/passwd), // which bypasses exact-match Set.has() checks. This helper catches both forms. function matchesDeniedFlag(arg: string, denySet: Set): boolean { @@ -145,6 +160,12 @@ export function validateGitExecArgs(args: string[]): void { throw new Error('git config write operations are not allowed via exec') } } + if (subcommand === 'init') { + validateInitArgs(args) + } + if (subcommand === 'commit') { + validateCommitArgs(args) + } if (subcommand === 'branch') { if (restArgs.some((a) => matchesDeniedFlag(a, BRANCH_DESTRUCTIVE_FLAGS))) { throw new Error('Destructive git branch flags are not allowed via exec') diff --git a/src/renderer/src/components/settings/repository-host-setup-options.test.ts b/src/renderer/src/components/settings/repository-host-setup-options.test.ts new file mode 100644 index 00000000000..3078b7e24a7 --- /dev/null +++ b/src/renderer/src/components/settings/repository-host-setup-options.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry' +import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { buildSetupHostOptions } from './repository-host-setup-options' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +function runtimeHost( + overrides: Partial = {} +): ExecutionHostRegistryEntry { + return { + id: 'runtime:env-1', + kind: 'runtime', + label: 'Remote Orca', + detail: 'Orca server', + health: 'available', + ...overrides + } as ExecutionHostRegistryEntry +} + +describe('buildSetupHostOptions', () => { + it('disables runtime hosts while capabilities are unknown', () => { + expect( + buildSetupHostOptions({ + projectHostSetups: [], + hostOptions: [runtimeHost()] + })[0] + ).toMatchObject({ + isAvailable: false, + detail: 'Checking host capabilities' + }) + }) + + it('enables runtime hosts that advertise project-host setup support', () => { + expect( + buildSetupHostOptions({ + projectHostSetups: [], + hostOptions: [ + runtimeHost({ + capabilities: [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY] + }) + ] + })[0] + ).toMatchObject({ + isAvailable: true, + detail: 'Orca server' + }) + }) +}) diff --git a/src/renderer/src/components/settings/repository-host-setup-options.ts b/src/renderer/src/components/settings/repository-host-setup-options.ts index 481637f1a1b..f253bf02347 100644 --- a/src/renderer/src/components/settings/repository-host-setup-options.ts +++ b/src/renderer/src/components/settings/repository-host-setup-options.ts @@ -69,17 +69,25 @@ function getHostSetupAvailability(host: ExecutionHostRegistryEntry): { ) } } - if ( - host.kind === 'runtime' && - host.capabilities && - !host.capabilities?.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY) - ) { - return { - isAvailable: false, - detail: translate( - 'auto.components.settings.RepositoryPane.hostSetupMissingCapability', - 'Update Orca on this host to set up projects' - ) + if (host.kind === 'runtime') { + const capabilities = host.capabilities + if (!capabilities) { + return { + isAvailable: false, + detail: translate( + 'auto.components.settings.RepositoryPane.hostSetupCheckingCapability', + 'Checking host capabilities' + ) + } + } + if (!capabilities.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY)) { + return { + isAvailable: false, + detail: translate( + 'auto.components.settings.RepositoryPane.hostSetupMissingCapability', + 'Update Orca on this host to set up projects' + ) + } } } return { diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index c46d1983f3e..152f5108839 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -1,11 +1,7 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useState } from 'react' import { useAppStore } from '@/store' -import { Dialog, DialogContent } from '@/components/ui/dialog' -import { track } from '@/lib/telemetry' import { useRemoteRepo } from './AddRepoSteps' import { useCreateRepo } from './useCreateRepo' -import { buildNestedRepoScanTelemetry } from '../../../../shared/nested-repo-telemetry' -import { AddRepoStepIndicator } from './AddRepoStepIndicator' import { AddRepoDialogStepContent } from './AddRepoDialogStepContent' import type { AddRepoDialogStep } from './add-repo-dialog-types' import { useAddRepoNestedReviewState } from './useAddRepoNestedReviewState' @@ -13,10 +9,13 @@ import { useAddRepoCloneFlow } from './useAddRepoCloneFlow' import { useAddRepoLocalFolderFlow } from './useAddRepoLocalFolderFlow' import { useAddRepoServerPathFlow } from './useAddRepoServerPathFlow' import { useAddRepoNestedImportFlow } from './useAddRepoNestedImportFlow' -import { AddRepoHostSelector } from './AddRepoHostSelector' import { useAddRepoHostSelection } from './use-add-repo-host-selection' import { useCompleteGitRepoAdd } from './use-complete-git-repo-add' import { useCreateProjectDefaults } from './useCreateProjectDefaults' +import { useAddRepoHostChangeReset } from './use-add-repo-host-change-reset' +import { AddRepoDialogChrome } from './AddRepoDialogChrome' +import { AddRepoHostSelectorSlot } from './AddRepoHostSelectorSlot' +import { useAddRepoRemoteNestedScan } from './use-add-repo-remote-nested-scan' const AddRepoDialog = React.memo(function AddRepoDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -64,6 +63,15 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setStep }) + const hostSelection = useAddRepoHostSelection({ isOpen: activeModal === 'add-repo', setStep }) + const selectedRuntimeEnvironmentId = + hostSelection.selectedParsedHost?.kind === 'runtime' + ? hostSelection.selectedParsedHost.environmentId + : null + const { showRemoteNestedRepoReview, trackRemoteNestedScanResult } = useAddRepoRemoteNestedScan({ + setActiveNestedScanId, + showNestedRepoReview + }) const { sshTargets, selectedTargetId, @@ -85,33 +93,10 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { closeModal, (repoId) => completeGitRepoAdd(repoId, 'ssh_remote_path'), scanNestedRepos, - (scan, selectedPath, connectionId, attemptId, inProgress, scanId) => { - setActiveNestedScanId(inProgress ? scanId : null) - showNestedRepoReview({ - scan, - selectedPath, - connectionId, - attemptId, - runtimeKind: 'ssh', - inProgress, - scanId - }) - }, - (scan, attemptId) => { - track( - 'add_repo_nested_scan_result', - buildNestedRepoScanTelemetry({ - attemptId, - surface: 'sidebar', - runtimeKind: 'ssh', - scan - }) - ) - } + showRemoteNestedRepoReview, + trackRemoteNestedScanResult ) - const hostSelection = useAddRepoHostSelection({ isOpen: activeModal === 'add-repo', setStep }) - const { createName, createParent, @@ -129,7 +114,11 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { fetchWorktrees, closeModal, (repoId) => completeGitRepoAdd(repoId, 'create_project'), - { hostId: hostSelection.selectedHostId, sshTargetId: hostSelection.selectedSshTargetId } + { + hostId: hostSelection.selectedHostId, + runtimeEnvironmentId: selectedRuntimeEnvironmentId, + sshTargetId: hostSelection.selectedSshTargetId + } ) const { @@ -142,7 +131,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { markCreateKindTouched } = useCreateProjectDefaults({ step, - activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, sshTargetId: hostSelection.selectedSshTargetId, createParent, setCreateParent, @@ -163,7 +152,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { handleClone } = useAddRepoCloneFlow({ step, - activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, sshTargetId: hostSelection.selectedSshTargetId, workspaceDir: settings?.workspaceDir, fetchWorktrees, @@ -173,11 +162,11 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const isOpen = activeModal === 'add-repo' const droppedLocalPath = typeof modalData.droppedLocalPath === 'string' ? modalData.droppedLocalPath : '' - const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) + const isRuntimeEnvironmentActive = Boolean(selectedRuntimeEnvironmentId) const { handleBrowse, resetLocalFolderFlow } = useAddRepoLocalFolderFlow({ isOpen, droppedLocalPath, - activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, addRepoPath, closeModal, fetchWorktrees, @@ -216,7 +205,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { nestedConnectionId, nestedGroupName, nestedImportScanId, - activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, fetchWorktrees, importNestedRepos, getNestedRepoRuntimeKind, @@ -250,11 +239,28 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { resetCreateState ]) - useEffect(() => { - if (!isOpen) { - resetState() - } - }, [isOpen, resetState]) + const resetHostScopedState = useCallback(() => { + setIsAdding(false) + setAddProjectBusyLabel(null) + resetServerPathFlow() + resetCloneFlow() + resetCreateDefaultState() + resetCreateState() + resetRemoteState() + }, [ + resetCloneFlow, + resetCreateDefaultState, + resetCreateState, + resetRemoteState, + resetServerPathFlow + ]) + + useAddRepoHostChangeReset({ + isOpen, + selectedHostId: hostSelection.selectedHostId, + onResetClosed: resetState, + onResetHostScopedState: resetHostScopedState + }) const handleBack = useCallback(() => { if (step === 'nested') { @@ -263,150 +269,144 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { resetState() }, [resetState, step, trackNestedBackAction]) - return ( - { - if (!open) { - if (step === 'nested' && !isAdding) { - trackNestedBackAction() - } - closeModal() - resetState() + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + if (step === 'nested' && !isAdding) { + trackNestedBackAction() } - }} + closeModal() + resetState() + } + }, + [closeModal, isAdding, resetState, step, trackNestedBackAction] + ) + + return ( + - *]:min-w-0 ${ - step === 'nested' ? 'max-h-[calc(100vh-2rem)] grid-rows-[auto_auto_minmax(0,1fr)]' : '' - }`} - > - - host.id === hostSelection.selectedHostId) - ?.label ?? hostSelection.selectedHostId - } - lockSshTargetSelection={hostSelection.selectedParsedHost?.kind === 'ssh'} - remotePath={remotePath} - remoteError={remoteError} - isAddingRemote={isAddingRemote} - isScanningRemoteNested={isScanningRemoteNested} - nestedScan={nestedScan} - nestedSelectedPaths={nestedSelectedPaths} - nestedGroupName={nestedGroupName} - createName={createName} - createParent={createParent} - createKind={createKind} - createError={createError} - isCreating={isCreating} - hostSelector={ - void hostSelection.handleSelectAddProjectHost(hostId)} - /> - } - showRemoteAction={false} - createDefaultParent={createDefaultParent} - createGitAvailability={createGitAvailability} - createRuntimeParentStatus={createRuntimeParentStatus} - createParentDefaultPending={createParentDefaultPending} - manualCreateParentEntry={ - isRuntimeEnvironmentActive || hostSelection.selectedParsedHost?.kind === 'ssh' - } - onBrowse={ - hostSelection.selectedParsedHost?.kind === 'ssh' - ? () => void handleOpenRemoteStep(hostSelection.selectedSshTargetId) - : handleBrowse - } - onOpenCloneStep={() => { - setCloneError(null) - setStep('clone') - }} - onOpenCreateStep={() => { - setCreateError(null) - setStep('create') - }} - onOpenRemoteStep={handleOpenRemoteStep} - onStopNestedScan={handleStopNestedScan} - onServerPathChange={setServerPath} - onAddServerPath={(kind) => void handleAddServerPath(kind)} - onSelectTarget={(id) => { - setSelectedTargetId(id) - setRemoteError(null) - }} - onRemotePathChange={(value) => { - setRemotePath(value) - setRemoteError(null) - }} - onAddRemoteRepo={handleAddRemoteRepo} - onOpenSshSettings={() => { - closeModal() - openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) - openSettingsPage() - }} - onConnectTarget={handleConnectTarget} - onStopRemoteNestedScan={stopRemoteNestedScan} - onCloneUrlChange={(value) => { - setCloneUrl(value) - setCloneError(null) - }} - onCloneDestinationChange={(value) => { - setCloneDestination(value) - setCloneError(null) - }} - onPickCloneDestination={handlePickDestination} - onClone={handleClone} - onNestedGroupNameChange={setNestedGroupName} - onNestedSelectedPathsChange={setNestedSelectedPaths} - onImportNestedRepos={(mode) => void handleImportNestedRepos(mode)} - onCreateNameChange={(value) => { - setCreateName(value) - setCreateError(null) - }} - onCreateParentChange={(value) => { - markCreateParentTouched(value) - setCreateParent(value) - setCreateError(null) - }} - onCreateKindChange={(kind) => { - markCreateKindTouched() - setCreateKind(kind) - setCreateError(null) - }} - onPickCreateParent={() => { - void handlePickParent().then((dir) => { - if (dir) { - markCreateParentTouched(dir) - } - }) - }} - onCreate={handleCreate} - /> - - + host.id === hostSelection.selectedHostId) + ?.label ?? hostSelection.selectedHostId + } + lockSshTargetSelection={hostSelection.selectedParsedHost?.kind === 'ssh'} + remotePath={remotePath} + remoteError={remoteError} + isAddingRemote={isAddingRemote} + isScanningRemoteNested={isScanningRemoteNested} + nestedScan={nestedScan} + nestedSelectedPaths={nestedSelectedPaths} + nestedGroupName={nestedGroupName} + createName={createName} + createParent={createParent} + createKind={createKind} + createError={createError} + isCreating={isCreating} + hostSelector={} + showRemoteAction={false} + browseHostKind={hostSelection.selectedParsedHost?.kind === 'ssh' ? 'ssh' : 'local'} + createDefaultParent={createDefaultParent} + createGitAvailability={createGitAvailability} + createRuntimeParentStatus={createRuntimeParentStatus} + createParentDefaultPending={createParentDefaultPending} + manualCreateParentEntry={ + isRuntimeEnvironmentActive || hostSelection.selectedParsedHost?.kind === 'ssh' + } + onBrowse={ + hostSelection.selectedParsedHost?.kind === 'ssh' + ? () => void handleOpenRemoteStep(hostSelection.selectedSshTargetId) + : handleBrowse + } + onOpenCloneStep={() => { + setCloneError(null) + setStep('clone') + }} + onOpenCreateStep={() => { + setCreateError(null) + setStep('create') + }} + onOpenRemoteStep={handleOpenRemoteStep} + onStopNestedScan={handleStopNestedScan} + onServerPathChange={setServerPath} + onAddServerPath={(kind) => void handleAddServerPath(kind)} + onSelectTarget={(id) => { + setSelectedTargetId(id) + setRemoteError(null) + }} + onRemotePathChange={(value) => { + setRemotePath(value) + setRemoteError(null) + }} + onAddRemoteRepo={handleAddRemoteRepo} + onOpenSshSettings={() => { + closeModal() + openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) + openSettingsPage() + }} + onConnectTarget={handleConnectTarget} + onStopRemoteNestedScan={stopRemoteNestedScan} + onCloneUrlChange={(value) => { + setCloneUrl(value) + setCloneError(null) + }} + onCloneDestinationChange={(value) => { + setCloneDestination(value) + setCloneError(null) + }} + onPickCloneDestination={handlePickDestination} + onClone={handleClone} + onNestedGroupNameChange={setNestedGroupName} + onNestedSelectedPathsChange={setNestedSelectedPaths} + onImportNestedRepos={(mode) => void handleImportNestedRepos(mode)} + onCreateNameChange={(value) => { + setCreateName(value) + setCreateError(null) + }} + onCreateParentChange={(value) => { + markCreateParentTouched(value) + setCreateParent(value) + setCreateError(null) + }} + onCreateKindChange={(kind) => { + markCreateKindTouched() + setCreateKind(kind) + setCreateError(null) + }} + onPickCreateParent={() => { + void handlePickParent().then((dir) => { + if (dir) { + markCreateParentTouched(dir) + } + }) + }} + onCreate={handleCreate} + /> + ) }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx b/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx new file mode 100644 index 00000000000..5c27fd8dd22 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import type { AddRepoDialogStep } from './add-repo-dialog-types' +import { AddRepoStepIndicator } from './AddRepoStepIndicator' + +export function AddRepoDialogChrome({ + children, + isAdding, + isOpen, + onBack, + onOpenChange, + step +}: { + children: ReactNode + isAdding: boolean + isOpen: boolean + onBack: () => void + onOpenChange: (open: boolean) => void + step: AddRepoDialogStep +}) { + return ( + + *]:min-w-0 ${ + step === 'nested' ? 'max-h-[calc(100vh-2rem)] grid-rows-[auto_auto_minmax(0,1fr)]' : '' + }`} + > + + {children} + + + ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index 0eb7d4de438..3fb669b949f 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -211,8 +211,47 @@ describe('AddRepoDialogStepContent nested imports', () => { expect(html).toContain('Open remote project') expect(html).toContain('openclaw 2') expect(html).toContain('Remote path') - expect(html).not.toContain('SSH target') + expect(html).toContain('SSH target') expect(html).not.toContain('github.com') expect(html).not.toContain('Connect') }) + + it('shows a connect affordance for a selected disconnected SSH host', () => { + const html = renderStepContent({ + step: 'remote', + lockSshTargetSelection: true, + selectedTargetId: 'openclaw-2', + sshTargets: [ + { + id: 'openclaw-2', + label: 'openclaw 2', + host: 'openclaw.example.com', + port: 22, + username: 'dev', + state: { + targetId: 'openclaw-2', + status: 'disconnected', + error: null, + reconnectAttempt: 0 + } + } + ] + }) + + expect(html).toContain('openclaw 2') + expect(html).toContain('Connect') + expect(html).toContain('placeholder="/home/user/project"') + expect(html).toContain('disabled=""') + }) + + it('uses SSH-aware copy on the add step when an SSH host is selected', () => { + const html = renderStepContent({ + step: 'add', + browseHostKind: 'ssh' + }) + + expect(html).toContain('Open SSH project') + expect(html).toContain('Existing Git repository or folder on this SSH host') + expect(html).not.toContain('Local project, Git repo, or folder with many repos') + }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index aa9bf1fcc90..d9e01804e6c 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -48,6 +48,7 @@ type AddRepoDialogStepContentProps = { showRemoteAction?: boolean canCreateProject?: boolean manualCreateParentEntry?: boolean + browseHostKind?: 'local' | 'ssh' createDefaultParent: string createGitAvailability: GitAvailability createRuntimeParentStatus: 'idle' | 'checking' | 'failed' @@ -117,6 +118,7 @@ export function AddRepoDialogStepContent({ showRemoteAction = true, canCreateProject = true, manualCreateParentEntry = isRuntimeEnvironmentActive, + browseHostKind = 'local', createDefaultParent, createGitAvailability, createRuntimeParentStatus, @@ -175,6 +177,7 @@ export function AddRepoDialogStepContent({ hostSelector={hostSelector} showRemoteAction={showRemoteAction} canCreateProject={canCreateProject} + browseHostKind={browseHostKind} onBrowse={onBrowse} onOpenCloneStep={onOpenCloneStep} onOpenRemoteStep={onOpenRemoteStep} diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx index 1cc82e78f91..6be72485fe2 100644 --- a/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx @@ -57,9 +57,9 @@ describe('AddRepoHostSelector', () => { expect(html).toContain('Builder') expect(html).toContain('Disconnected') - expect(html).toContain('aria-disabled="true"') - expect(html).toContain('cursor-not-allowed') - expect(html).toContain('opacity-55') + expect(html).toContain('aria-disabled="false"') + expect(html).not.toContain('cursor-not-allowed') + expect(html).not.toContain('opacity-55') }) it('shows exact update guidance for incompatible runtime hosts', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelectorSlot.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelectorSlot.tsx new file mode 100644 index 00000000000..0fe1236f5a5 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoHostSelectorSlot.tsx @@ -0,0 +1,18 @@ +import { AddRepoHostSelector } from './AddRepoHostSelector' +import type { useAddRepoHostSelection } from './use-add-repo-host-selection' + +export function AddRepoHostSelectorSlot({ + hostSelection +}: { + hostSelection: ReturnType +}) { + return ( + void hostSelection.handleSelectAddProjectHost(hostId)} + /> + ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx index 917ba775d7e..c79f52a2136 100644 --- a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx @@ -46,6 +46,8 @@ export function RemoteStep({ const selectedTargetLabel = selectedTarget?.label || (selectedTarget ? `${selectedTarget.username}@${selectedTarget.host}` : selectedTargetId) + const selectedTargetStatus = selectedTarget?.state?.status ?? 'disconnected' + const selectedTargetConnected = selectedTargetStatus === 'connected' if (browsing && selectedTargetId) { return ( @@ -138,6 +140,18 @@ export function RemoteStep({ )} + ) : selectedTarget ? ( +
+ + undefined} + onConnect={onConnectTarget} + /> +
) : null}
@@ -161,14 +175,14 @@ export function RemoteStep({ '/home/user/project' )} className="h-8 text-xs flex-1" - disabled={isAddingRemote || !selectedTargetId} + disabled={isAddingRemote || !selectedTargetId || !selectedTargetConnected} /> @@ -179,7 +193,9 @@ export function RemoteStep({