Polish remote host add project flows

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 23:39:47 -07:00
co-authored by Orca
parent 5a89cf0a13
commit 6b976d9601
39 changed files with 1094 additions and 301 deletions
+93 -2
View File
@@ -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({
+46 -32
View File
@@ -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<string> {
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<string, number>()
const pendingAbortCleanupByPath = new Map<string, Promise<void>>()
const cloneInFlightByPath = new Map<string, Promise<void>>()
const remoteCloneInFlightByPath = new Set<string>()
const activeNestedRepoScans = new Map<string, AbortController>()
type CompletedNestedRepoScan = {
scan: NestedRepoScanResult
@@ -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: [],
+7
View File
@@ -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?.()
}
+18 -1
View File
@@ -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')
})
})
})
+23 -2
View File
@@ -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 <message>')
}
}
// 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<string>): 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')
@@ -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> = {}
): 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'
})
})
})
@@ -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 {
@@ -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 (
<Dialog
open={isOpen}
onOpenChange={(open) => {
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 (
<AddRepoDialogChrome
isOpen={isOpen}
step={step}
isAdding={isAdding}
onBack={handleBack}
onOpenChange={handleOpenChange}
>
<DialogContent
className={`min-w-0 overflow-hidden sm:max-w-lg [&>*]:min-w-0 ${
step === 'nested' ? 'max-h-[calc(100vh-2rem)] grid-rows-[auto_auto_minmax(0,1fr)]' : ''
}`}
>
<AddRepoStepIndicator step={step} isAdding={isAdding} onBack={handleBack} />
<AddRepoDialogStepContent
step={step}
isRuntimeEnvironmentActive={isRuntimeEnvironmentActive}
activeRuntimeEnvironmentId={settings?.activeRuntimeEnvironmentId}
isSshLikely={false}
repoCount={repos.length}
isAdding={isAdding}
addProjectBusyLabel={addProjectBusyLabel}
nestedScanInProgress={nestedScanInProgress}
nestedScanId={nestedScanId}
serverPath={serverPath}
isAddingServerPath={isAddingServerPath}
cloneUrl={cloneUrl}
cloneDestination={cloneDestination}
cloneError={cloneError}
cloneProgress={cloneProgress}
isCloning={isCloning}
sshTargets={sshTargets}
selectedTargetId={selectedTargetId}
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}
isScanningRemoteNested={isScanningRemoteNested}
nestedScan={nestedScan}
nestedSelectedPaths={nestedSelectedPaths}
nestedGroupName={nestedGroupName}
createName={createName}
createParent={createParent}
createKind={createKind}
createError={createError}
isCreating={isCreating}
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 || 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}
/>
</DialogContent>
</Dialog>
<AddRepoDialogStepContent
step={step}
isRuntimeEnvironmentActive={isRuntimeEnvironmentActive}
activeRuntimeEnvironmentId={selectedRuntimeEnvironmentId}
isSshLikely={false}
repoCount={repos.length}
isAdding={isAdding}
addProjectBusyLabel={addProjectBusyLabel}
nestedScanInProgress={nestedScanInProgress}
nestedScanId={nestedScanId}
serverPath={serverPath}
isAddingServerPath={isAddingServerPath}
cloneUrl={cloneUrl}
cloneDestination={cloneDestination}
cloneError={cloneError}
cloneProgress={cloneProgress}
isCloning={isCloning}
sshTargets={sshTargets}
selectedTargetId={selectedTargetId}
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}
isScanningRemoteNested={isScanningRemoteNested}
nestedScan={nestedScan}
nestedSelectedPaths={nestedSelectedPaths}
nestedGroupName={nestedGroupName}
createName={createName}
createParent={createParent}
createKind={createKind}
createError={createError}
isCreating={isCreating}
hostSelector={<AddRepoHostSelectorSlot hostSelection={hostSelection} />}
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}
/>
</AddRepoDialogChrome>
)
})
@@ -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 (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className={`min-w-0 overflow-hidden sm:max-w-lg [&>*]:min-w-0 ${
step === 'nested' ? 'max-h-[calc(100vh-2rem)] grid-rows-[auto_auto_minmax(0,1fr)]' : ''
}`}
>
<AddRepoStepIndicator step={step} isAdding={isAdding} onBack={onBack} />
{children}
</DialogContent>
</Dialog>
)
}
@@ -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')
})
})
@@ -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}
@@ -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', () => {
@@ -0,0 +1,18 @@
import { AddRepoHostSelector } from './AddRepoHostSelector'
import type { useAddRepoHostSelection } from './use-add-repo-host-selection'
export function AddRepoHostSelectorSlot({
hostSelection
}: {
hostSelection: ReturnType<typeof useAddRepoHostSelection>
}) {
return (
<AddRepoHostSelector
hosts={hostSelection.hostOptions}
selectedHostId={hostSelection.selectedHostId}
open={hostSelection.hostSelectorOpen}
onOpenChange={hostSelection.setHostSelectorOpen}
onSelectHost={(hostId) => void hostSelection.handleSelectAddProjectHost(hostId)}
/>
)
}
@@ -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({
</div>
)}
</div>
) : selectedTarget ? (
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.AddRepoRemoteStep.44637f43bd', 'SSH target')}
</label>
<SshTargetRow
target={selectedTarget}
isSelected
onSelect={() => undefined}
onConnect={onConnectTarget}
/>
</div>
) : null}
<div className="space-y-1">
@@ -161,14 +175,14 @@ export function RemoteStep({
'/home/user/project'
)}
className="h-8 text-xs flex-1"
disabled={isAddingRemote || !selectedTargetId}
disabled={isAddingRemote || !selectedTargetId || !selectedTargetConnected}
/>
<Button
variant="outline"
size="sm"
className="h-8 px-2 shrink-0"
onClick={() => setBrowsing(true)}
disabled={!selectedTargetId || isAddingRemote}
disabled={!selectedTargetId || !selectedTargetConnected || isAddingRemote}
>
<FolderOpen className="size-3.5" />
</Button>
@@ -179,7 +193,9 @@ export function RemoteStep({
<Button
onClick={onAdd}
disabled={!selectedTargetId || !remotePath.trim() || isAddingRemote}
disabled={
!selectedTargetId || !selectedTargetConnected || !remotePath.trim() || isAddingRemote
}
className="w-full"
>
{isAddingRemote
@@ -69,6 +69,7 @@ type AddRepoLocalStartStepProps = {
hostSelector?: ReactNode
showRemoteAction?: boolean
canCreateProject?: boolean
browseHostKind?: 'local' | 'ssh'
onBrowse: () => void
onOpenCloneStep: () => void
onOpenRemoteStep: () => void
@@ -86,6 +87,7 @@ export function AddRepoLocalStartStep({
hostSelector,
showRemoteAction = true,
canCreateProject = true,
browseHostKind = 'local',
onBrowse,
onOpenCloneStep,
onOpenRemoteStep,
@@ -101,7 +103,8 @@ export function AddRepoLocalStartStep({
onOpenRemoteStep,
onOpenCreateStep,
showRemoteAction,
canCreateProject
canCreateProject,
browseHostKind
})
// The white fill + ⏎ chip is a roving selection indicator, not a fixed "primary" badge:
@@ -83,7 +83,7 @@ export function FolderWorkspaceComposerDialog({
connectionId: selectedRepoConnectionId,
status: selectedRepoSshState?.status ?? null
})
const { detectedIds } = useDetectedAgents(null)
const { detectedIds } = useDetectedAgents(selectedRepoConnectionId)
const detectedAgentIds = useMemo(() => (detectedIds ? new Set(detectedIds) : null), [detectedIds])
const [name, setName] = useState('')
const [note, setNote] = useState('')
@@ -184,6 +184,8 @@ import {
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { splitWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-host-split'
import {
ALL_EXECUTION_HOSTS_SCOPE,
getRepoExecutionHostId,
getSettingsFocusedExecutionHostId,
type ExecutionHostId,
parseExecutionHostId
@@ -4519,6 +4521,50 @@ const WorktreeList = React.memo(function WorktreeList({
worktreeLineageById,
worktreeMap
])
const defaultHostId = getSettingsFocusedExecutionHostId(settings)
const visibleHostIdSet = useMemo(() => {
const visibleHostIds =
visibleWorkspaceHostIds ??
(workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [workspaceHostScope])
return visibleHostIds ? new Set<ExecutionHostId>(visibleHostIds) : null
}, [visibleWorkspaceHostIds, workspaceHostScope])
const visibleReposForRows = useMemo(() => {
if (!visibleHostIdSet) {
return repos
}
return repos.filter((repo) => {
const hostId =
repo.connectionId || repo.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId
return visibleHostIdSet.has(hostId)
})
}, [defaultHostId, repos, visibleHostIdSet])
const visibleProjectGroupsForRows = useMemo(() => {
if (!visibleHostIdSet) {
return projectGroups
}
return projectGroups.filter((group) => {
const hostId = group.connectionId
? (`ssh:${encodeURIComponent(group.connectionId)}` as ExecutionHostId)
: defaultHostId
return visibleHostIdSet.has(hostId)
})
}, [defaultHostId, projectGroups, visibleHostIdSet])
const visibleFolderWorkspacesForRows = useMemo(() => {
if (!visibleHostIdSet) {
return folderWorkspaces
}
const projectGroupById = new Map(projectGroups.map((group) => [group.id, group]))
return folderWorkspaces.filter((folderWorkspace) => {
const connectionId =
folderWorkspace.connectionId ??
projectGroupById.get(folderWorkspace.projectGroupId)?.connectionId ??
null
const hostId = connectionId
? (`ssh:${encodeURIComponent(connectionId)}` as ExecutionHostId)
: defaultHostId
return visibleHostIdSet.has(hostId)
})
}, [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet])
const repoOrder = useMemo(() => {
const map = new Map<string, number>()
repos.forEach((r, i) => map.set(r.id, i))
@@ -4534,15 +4580,20 @@ const WorktreeList = React.memo(function WorktreeList({
.map(([repoId]) => repoId)
)
return buildImportedWorktreesCardCandidates({
repos,
repos: visibleReposForRows,
detectedWorktreesByRepo,
filterRepoIds,
forceVisibleRepoIds
})
}, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, repos])
}, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows])
const placeholderRepoIds = useMemo(() => {
return getEmptyProjectPlaceholderRepoIds({ groupBy, repos, worktreesByRepo, filterRepoIds })
}, [filterRepoIds, groupBy, repos, worktreesByRepo])
return getEmptyProjectPlaceholderRepoIds({
groupBy,
repos: visibleReposForRows,
worktreesByRepo,
filterRepoIds
})
}, [filterRepoIds, groupBy, visibleReposForRows, worktreesByRepo])
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
// Why: buildRows only needs which creates exist and their repo. Subscribe on a
@@ -4582,12 +4633,12 @@ const WorktreeList = React.memo(function WorktreeList({
worktreeMap,
true,
settings,
projectGroups,
visibleProjectGroupsForRows,
placeholderRepoIds,
importedWorktreesByRepo,
pendingCreations,
projectGrouping,
folderWorkspaces
visibleFolderWorkspacesForRows
),
[
groupBy,
@@ -4601,15 +4652,14 @@ const WorktreeList = React.memo(function WorktreeList({
worktreeLineageById,
worktreeMap,
settings,
projectGroups,
projectGrouping,
visibleProjectGroupsForRows,
visibleFolderWorkspacesForRows,
placeholderRepoIds,
importedWorktreesByRepo,
pendingCreations,
folderWorkspaces
pendingCreations
]
)
const defaultHostId = getSettingsFocusedExecutionHostId(settings)
const hostOptions = useMemo(
() =>
buildSidebarHostOptions({
@@ -1,5 +1,5 @@
import type { SidebarHostOption } from './sidebar-host-options'
export function canSelectAddRepoHost(host: Pick<SidebarHostOption, 'health'>): boolean {
return host.health === 'local' || host.health === 'available'
export function canSelectAddRepoHost(host: Pick<SidebarHostOption, 'health' | 'kind'>): boolean {
return host.health === 'local' || host.health === 'available' || host.kind === 'ssh'
}
@@ -9,6 +9,7 @@ export type AddRepoLocalStartActionHandlers = {
onOpenCreateStep: () => void
showRemoteAction?: boolean
canCreateProject?: boolean
browseHostKind?: 'local' | 'ssh'
}
export type AddRepoLocalStartAction = {
@@ -27,7 +28,8 @@ export function getAddRepoLocalStartActions({
onOpenRemoteStep,
onOpenCreateStep,
showRemoteAction = true,
canCreateProject = true
canCreateProject = true,
browseHostKind = 'local'
}: { isSshLikely: boolean } & AddRepoLocalStartActionHandlers): {
primaryAction: AddRepoLocalStartAction
secondaryActions: AddRepoLocalStartAction[]
@@ -35,14 +37,26 @@ export function getAddRepoLocalStartActions({
const primaryAction = {
kind: 'browse' as const,
icon: FolderOpen,
title: translate(
'auto.components.sidebar.add.repo.local.start.actions.2281fdc8c7',
'Browse folder'
),
description: translate(
'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
'Local project, Git repo, or folder with many repos'
),
title:
browseHostKind === 'ssh'
? translate(
'auto.components.sidebar.add.repo.local.start.actions.sshBrowseTitle',
'Open SSH project'
)
: translate(
'auto.components.sidebar.add.repo.local.start.actions.2281fdc8c7',
'Browse folder'
),
description:
browseHostKind === 'ssh'
? translate(
'auto.components.sidebar.add.repo.local.start.actions.sshBrowseDescription',
'Existing Git repository or folder on this SSH host'
)
: translate(
'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
'Local project, Git repo, or folder with many repos'
),
onClick: onBrowse
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { Repo, Worktree } from '../../../../shared/types'
import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types'
import type { Row } from './worktree-list-groups'
import { addHostSectionRows, type HostSectionRow } from './host-section-rows'
@@ -66,6 +66,48 @@ function item(id: string, project: Repo): Extract<Row, { type: 'item' }> {
}
}
function folderWorkspaceRow(
connectionId: string | null
): Extract<Row, { type: 'folder-workspace' }> {
const projectGroup: ProjectGroup = {
id: 'group-1',
name: 'Remote folder',
parentPath: '/srv/project',
connectionId,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const folderWorkspace: FolderWorkspace = {
id: 'folder-1',
projectGroupId: projectGroup.id,
name: 'Folder workspace',
folderPath: '/srv/project',
connectionId,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
return {
type: 'folder-workspace',
key: 'folder-workspace:folder-1',
folderWorkspace,
projectGroup,
depth: 0,
groupDepth: 0
}
}
function rowKey(row: HostSectionRow): string {
return row.type === 'item' ? row.worktree.id : row.key
}
@@ -290,6 +332,35 @@ describe('addHostSectionRows', () => {
])
})
it('groups SSH folder workspace rows under their connection host', () => {
const local = repo('local')
const rows: Row[] = [repoHeader(local), item('local-wt', local), folderWorkspaceRow('ssh-1')]
const sectioned = addHostSectionRows({
rows,
hostOptions: [
{
id: 'local',
kind: 'local',
label: 'Local Mac',
detail: 'This computer',
health: 'local'
},
{ id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' }
],
workspaceHostScope: 'all',
defaultHostId: 'local'
})
expect(sectioned.map(rowKey)).toEqual([
'host:local',
'repo:local',
'local-wt',
'host:ssh:ssh-1',
'folder-workspace:folder-1'
])
})
it('carries the SSH connection status through to the host header row', () => {
const local = repo('local')
const ssh = repo('ssh', 'ssh-1')
@@ -9,7 +9,7 @@ import {
import type { ExecutionHostHealth } from '../../../../shared/execution-host-registry'
import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import type { Repo } from '../../../../shared/types'
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types'
import type { Row } from './worktree-list-groups'
export type HostHeaderRow = {
@@ -52,6 +52,19 @@ function getRepoHostId(
return defaultHostId
}
function getSshHostId(connectionId: string): ExecutionHostId {
return `ssh:${encodeURIComponent(connectionId)}` as ExecutionHostId
}
function getFolderWorkspaceHostId(
folderWorkspace: Pick<FolderWorkspace, 'connectionId'>,
projectGroup: Pick<ProjectGroup, 'connectionId'>,
defaultHostId: ExecutionHostId
): ExecutionHostId {
const connectionId = folderWorkspace.connectionId ?? projectGroup.connectionId
return connectionId ? getSshHostId(connectionId) : defaultHostId
}
function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null {
switch (row.type) {
case 'item':
@@ -60,7 +73,7 @@ function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId
case 'imported-worktrees-card':
return getRepoHostId(row.repo, defaultHostId)
case 'folder-workspace':
return getRepoHostId(undefined, defaultHostId)
return getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, defaultHostId)
case 'header':
return row.repo ? getRepoHostId(row.repo, defaultHostId) : null
}
@@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react'
export function useAddRepoHostChangeReset({
isOpen,
selectedHostId,
onResetClosed,
onResetHostScopedState
}: {
isOpen: boolean
selectedHostId: string
onResetClosed: () => void
onResetHostScopedState: () => void
}) {
const previousSelectedHostIdRef = useRef(selectedHostId)
useEffect(() => {
if (!isOpen) {
previousSelectedHostIdRef.current = selectedHostId
onResetClosed()
}
}, [isOpen, onResetClosed, selectedHostId])
useEffect(() => {
if (!isOpen || previousSelectedHostIdRef.current === selectedHostId) {
return
}
// Why: Add Project form fields are host-path scoped, so switching hosts must
// clear typed paths and pending defaults before they can be submitted.
previousSelectedHostIdRef.current = selectedHostId
onResetHostScopedState()
}, [isOpen, onResetHostScopedState, selectedHostId])
}
@@ -127,7 +127,7 @@ describe('useAddRepoHostSelection', () => {
expect(setStep).toHaveBeenCalledWith('add')
})
it('falls back from a disconnected selected host to the local host', async () => {
it('keeps a disconnected SSH host selected so the remote step can connect it', async () => {
mocks.stateValues = ['ssh:ssh-1', false]
mocks.hostOptions[1] = {
...mocks.hostOptions[1],
@@ -137,11 +137,11 @@ describe('useAddRepoHostSelection', () => {
const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() })
expect(result.selectedHostId).toBe('local')
expect(result.selectedSshTargetId).toBeNull()
expect(result.selectedHostId).toBe('ssh:ssh-1')
expect(result.selectedSshTargetId).toBe('ssh-1')
})
it('does not select a disconnected host', async () => {
it('selects a disconnected SSH host so the user can connect it inline', async () => {
mocks.stateValues = ['local', false]
mocks.hostOptions[1] = {
...mocks.hostOptions[1],
@@ -154,8 +154,8 @@ describe('useAddRepoHostSelection', () => {
await result.handleSelectAddProjectHost('ssh:ssh-1')
expect(mocks.storeState.switchRuntimeEnvironment).not.toHaveBeenCalled()
expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('ssh:ssh-1')
expect(setStep).not.toHaveBeenCalled()
expect(mocks.stateSetters[0]).toHaveBeenCalledWith('ssh:ssh-1')
expect(setStep).toHaveBeenCalledWith('add')
})
it('does not auto-select the active runtime host while it is unavailable', async () => {
@@ -0,0 +1,60 @@
import { useCallback } from 'react'
import { track } from '@/lib/telemetry'
import { buildNestedRepoScanTelemetry } from '../../../../shared/nested-repo-telemetry'
import type { NestedRepoScanResult } from '../../../../shared/types'
export function useAddRepoRemoteNestedScan({
setActiveNestedScanId,
showNestedRepoReview
}: {
setActiveNestedScanId: (scanId: string | null) => void
showNestedRepoReview: (options: {
scan: NestedRepoScanResult
selectedPath: string
connectionId: string
attemptId: string
runtimeKind: 'ssh'
inProgress: boolean
scanId: string | null
}) => void
}) {
const showRemoteNestedRepoReview = useCallback(
(
scan: NestedRepoScanResult,
selectedPath: string,
connectionId: string,
attemptId: string,
inProgress: boolean,
scanId: string | null
) => {
setActiveNestedScanId(inProgress ? scanId : null)
showNestedRepoReview({
scan,
selectedPath,
connectionId,
attemptId,
runtimeKind: 'ssh',
inProgress,
scanId
})
},
[setActiveNestedScanId, showNestedRepoReview]
)
const trackRemoteNestedScanResult = useCallback(
(scan: NestedRepoScanResult | null, attemptId: string) => {
track(
'add_repo_nested_scan_result',
buildNestedRepoScanTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind: 'ssh',
scan
})
)
},
[]
)
return { showRemoteNestedRepoReview, trackRemoteNestedScanResult }
}
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
cloneLocal: vi.fn(),
pickDirectory: vi.fn(),
onCloneProgress: vi.fn(() => vi.fn()),
callRuntimeRpc: vi.fn(),
fetchWorktrees: vi.fn(),
onGitRepoReady: vi.fn()
}))
@@ -64,7 +65,7 @@ vi.mock('@/store', () => {
vi.mock('@/runtime/runtime-rpc-client', () => ({
getActiveRuntimeTarget: () => ({ kind: 'local' }),
callRuntimeRpc: vi.fn()
callRuntimeRpc: mocks.callRuntimeRpc
}))
vi.mock('sonner', () => ({
@@ -110,6 +111,7 @@ describe('useAddRepoCloneFlow', () => {
it('clones through the selected SSH target', async () => {
const repo = makeRepo({ connectionId: 'ssh-1' })
mocks.cloneRemote.mockResolvedValue(repo)
mocks.callRuntimeRpc.mockReset()
mocks.fetchWorktrees.mockResolvedValue(true)
const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow')
@@ -174,4 +176,37 @@ describe('useAddRepoCloneFlow', () => {
"Clone failed: fatal: destination path 'orca' already exists and is not an empty directory."
)
})
it('clones through the selected runtime environment', async () => {
const repo = makeRepo({ id: 'runtime-repo', executionHostId: 'runtime:env-1' })
mocks.callRuntimeRpc.mockResolvedValue({ repo })
mocks.fetchWorktrees.mockResolvedValue(true)
const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow')
const result = useAddRepoCloneFlow({
step: 'clone',
activeRuntimeEnvironmentId: 'env-1',
sshTargetId: null,
workspaceDir: '/local/workspace',
fetchWorktrees: mocks.fetchWorktrees,
onGitRepoReady: mocks.onGitRepoReady
})
await result.handleClone()
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-1' },
'repo.clone',
{
url: 'https://github.com/stablyai/orca.git',
destination: '/srv'
},
{ timeoutMs: 10 * 60_000 }
)
expect(mocks.cloneLocal).not.toHaveBeenCalled()
expect(mocks.cloneRemote).not.toHaveBeenCalled()
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true
})
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'clone_url')
})
})
@@ -116,7 +116,12 @@ export function useAddRepoCloneFlow({
setCloneError(null)
setCloneProgress(null)
try {
const target = getActiveRuntimeTarget(useAppStore.getState().settings)
const target = activeRuntimeEnvironmentId?.trim()
? { kind: 'environment' as const, environmentId: activeRuntimeEnvironmentId.trim() }
: getActiveRuntimeTarget({
...useAppStore.getState().settings,
activeRuntimeEnvironmentId: null
})
const repo = sshTargetId?.trim()
? await window.api.repos.cloneRemote({
connectionId: sshTargetId.trim(),
@@ -174,7 +179,14 @@ export function useAddRepoCloneFlow({
setIsCloning(false)
}
}
}, [cloneUrl, cloneDestination, fetchWorktrees, onGitRepoReady, sshTargetId])
}, [
activeRuntimeEnvironmentId,
cloneUrl,
cloneDestination,
fetchWorktrees,
onGitRepoReady,
sshTargetId
])
return {
cloneUrl,
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
},
createRepo: vi.fn(),
createRemoteRepo: vi.fn(),
callRuntimeRpc: vi.fn(),
fetchWorktrees: vi.fn(),
onGitRepoReady: vi.fn(),
activateAndRevealWorktree: vi.fn(),
@@ -73,6 +74,11 @@ vi.mock('sonner', () => ({
}
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
getActiveRuntimeTarget: () => ({ kind: 'local' }),
callRuntimeRpc: mocks.callRuntimeRpc
}))
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-created',
@@ -139,10 +145,11 @@ describe('useCreateRepo default-checkout handoff', () => {
})
it('does not return a parent path when the runtime target blocks the local picker', async () => {
mocks.storeState.settings.activeRuntimeEnvironmentId = 'env-1'
const { useCreateRepo } = await import('./useCreateRepo')
const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady)
const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, {
runtimeEnvironmentId: 'env-1'
})
await expect(result.handlePickParent()).resolves.toBeNull()
expect(window.api.repos.pickDirectory).not.toHaveBeenCalled()
@@ -219,4 +226,34 @@ describe('useCreateRepo default-checkout handoff', () => {
})
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id)
})
it('creates projects through the selected runtime environment', async () => {
const repo = makeRepo({ executionHostId: 'runtime:env-1', path: '/srv/created' })
mocks.callRuntimeRpc.mockResolvedValue({ repo })
mocks.fetchWorktrees.mockResolvedValue(true)
const { useCreateRepo } = await import('./useCreateRepo')
const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, {
hostId: 'runtime:env-1',
runtimeEnvironmentId: 'env-1'
})
await result.handleCreate()
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-1' },
'repo.create',
{
parentPath: '/projects',
name: 'created',
kind: 'git'
},
{ timeoutMs: 60_000 }
)
expect(mocks.createRepo).not.toHaveBeenCalled()
expect(mocks.createRemoteRepo).not.toHaveBeenCalled()
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true
})
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id)
})
})
@@ -20,7 +20,11 @@ export function useCreateRepo(
) => Promise<boolean>,
closeModal: () => void,
onGitRepoReady?: (repoId: string) => void | Promise<void>,
options: { hostId?: string | null; sshTargetId?: string | null } = {}
options: {
hostId?: string | null
runtimeEnvironmentId?: string | null
sshTargetId?: string | null
} = {}
) {
const [createName, setCreateName] = useState('')
const [createParent, setCreateParent] = useState('')
@@ -58,7 +62,7 @@ export function useCreateRepo(
)
return null
}
if (useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) {
if (options.runtimeEnvironmentId?.trim()) {
// Why: the native folder picker returns a client-local path. Runtime
// project creation needs an explicit server parent path.
toast.error(
@@ -77,7 +81,7 @@ export function useCreateRepo(
return dir
}
return null
}, [mountedRef, options.sshTargetId])
}, [mountedRef, options.runtimeEnvironmentId, options.sshTargetId])
const handleCreate = useCallback(async () => {
const name = createName.trim()
@@ -90,7 +94,12 @@ export function useCreateRepo(
setIsCreating(true)
setCreateError(null)
try {
const target = getActiveRuntimeTarget(useAppStore.getState().settings)
const target = options.runtimeEnvironmentId?.trim()
? { kind: 'environment' as const, environmentId: options.runtimeEnvironmentId.trim() }
: getActiveRuntimeTarget({
...useAppStore.getState().settings,
activeRuntimeEnvironmentId: null
})
const result = options.sshTargetId
? await window.api.repos.createRemote({
connectionId: options.sshTargetId,
@@ -221,6 +230,7 @@ export function useCreateRepo(
mountedRef,
closeModal,
onGitRepoReady,
options.runtimeEnvironmentId,
options.sshTargetId
])
+5 -2
View File
@@ -3706,7 +3706,9 @@
"sshCreateUnavailable": "Not available for SSH hosts yet",
"3d162cc76f": "Remote project",
"fb4fc5380e": "Local project, Git repo, or folder with many repos",
"2281fdc8c7": "Browse folder"
"2281fdc8c7": "Browse folder",
"sshBrowseTitle": "Open SSH project",
"sshBrowseDescription": "Existing Git repository or folder on this SSH host"
}
}
}
@@ -5213,7 +5215,8 @@
"cloneHost": "Clone",
"creatingPendingSetup": "Creating...",
"createPendingSetup": "Track setup",
"499a437335": "Identity"
"499a437335": "Identity",
"hostSetupCheckingCapability": "Checking host capabilities"
},
"RepositorySourceControlAiActionRows": {
"548a6e1281": "Command template",
+5 -2
View File
@@ -3692,7 +3692,9 @@
"3d162cc76f": "Proyecto remoto",
"fb4fc5380e": "Proyecto local, repo de Git o carpeta con muchos repos",
"2281fdc8c7": "Explorar carpeta",
"sshCreateUnavailable": "Not available for SSH hosts yet"
"sshCreateUnavailable": "Not available for SSH hosts yet",
"sshBrowseTitle": "Open SSH project",
"sshBrowseDescription": "Existing Git repository or folder on this SSH host"
}
}
}
@@ -5177,7 +5179,8 @@
"cloningHost": "Cloning...",
"cloneHost": "Clone",
"creatingPendingSetup": "Creating...",
"createPendingSetup": "Track setup"
"createPendingSetup": "Track setup",
"hostSetupCheckingCapability": "Checking host capabilities"
},
"RepositorySourceControlAiActionRows": {
"548a6e1281": "Plantilla de comando",
+5 -2
View File
@@ -3683,7 +3683,9 @@
"a6c20dca96": "SSH ターゲットからプロジェクトを開く",
"3d162cc76f": "リモートプロジェクト",
"fb4fc5380e": "ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー",
"2281fdc8c7": "フォルダを参照する"
"2281fdc8c7": "フォルダを参照する",
"sshBrowseTitle": "Open SSH project",
"sshBrowseDescription": "Existing Git repository or folder on this SSH host"
}
}
}
@@ -5198,7 +5200,8 @@
"cloneHost": "クローン",
"creatingPendingSetup": "作成中...",
"createPendingSetup": "セットアップを追跡",
"499a437335": "身元"
"499a437335": "身元",
"hostSetupCheckingCapability": "Checking host capabilities"
},
"RepositorySourceControlAiActionRows": {
"548a6e1281": "コマンドテンプレート",
+5 -2
View File
@@ -3683,7 +3683,9 @@
"a6c20dca96": "SSH 대상에서 프로젝트 열기",
"3d162cc76f": "원격 프로젝트",
"fb4fc5380e": "로컬 프로젝트, Git repo 또는 repos가 많은 폴더",
"2281fdc8c7": "폴더 찾아보기"
"2281fdc8c7": "폴더 찾아보기",
"sshBrowseTitle": "Open SSH project",
"sshBrowseDescription": "Existing Git repository or folder on this SSH host"
}
}
}
@@ -5162,7 +5164,8 @@
"cloneHost": "클론",
"creatingPendingSetup": "생성 중...",
"createPendingSetup": "설정 추적",
"499a437335": "신원"
"499a437335": "신원",
"hostSetupCheckingCapability": "Checking host capabilities"
},
"RepositorySourceControlAiActionRows": {
"548a6e1281": "명령 템플릿",
+5 -2
View File
@@ -3683,7 +3683,9 @@
"a6c20dca96": "从 SSH 目标打开项目",
"3d162cc76f": "远程项目",
"fb4fc5380e": "本地项目、Git repo 或包含多个 repos 的文件夹",
"2281fdc8c7": "浏览文件夹"
"2281fdc8c7": "浏览文件夹",
"sshBrowseTitle": "Open SSH project",
"sshBrowseDescription": "Existing Git repository or folder on this SSH host"
}
}
}
@@ -5162,7 +5164,8 @@
"cloneHost": "克隆",
"creatingPendingSetup": "正在创建...",
"createPendingSetup": "跟踪设置",
"499a437335": "身份"
"499a437335": "身份",
"hostSetupCheckingCapability": "Checking host capabilities"
},
"RepositorySourceControlAiActionRows": {
"548a6e1281": "命令模板",
@@ -227,4 +227,20 @@ describe('buildProjectHostSetupOptions', () => {
isAvailable: false
})
})
it('marks runtime hosts with unknown capabilities as unavailable while checking', () => {
const options = buildProjectHostSetupOptions({
projectId: 'project-1',
eligibleRepos: [repo('local-repo')],
hosts: [host('local'), host('runtime:gpu', { label: 'GPU VM' })],
projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')]
})
expect(options.at(-1)).toMatchObject({
id: 'needs-setup:runtime:gpu',
kind: 'needs-setup',
detail: 'Checking host capabilities',
isAvailable: false
})
})
})
@@ -153,14 +153,18 @@ function getHostSetupAvailability(host: ExecutionHostRegistryEntry): {
detail: 'Orca server version is incompatible'
}
}
if (
host.kind === 'runtime' &&
host.capabilities &&
!host.capabilities?.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY)
) {
return {
isAvailable: false,
detail: 'Update Orca on this host to set up projects'
if (host.kind === 'runtime') {
if (!host.capabilities) {
return {
isAvailable: false,
detail: 'Checking host capabilities'
}
}
if (!host.capabilities.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY)) {
return {
isAvailable: false,
detail: 'Update Orca on this host to set up projects'
}
}
}
return {
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Project, ProjectHostSetup } from '../../../../shared/types'
import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
@@ -22,6 +22,15 @@ const project: Project = {
updatedAt: 1
}
const runtimeRepo: Repo = {
id: 'runtime-repo',
path: '/srv/project',
displayName: 'Project',
badgeColor: '#111',
addedAt: 1,
executionHostId: 'runtime:env-1'
}
const runtimeSetup: ProjectHostSetup = {
id: 'setup-gpu',
projectId: project.id,
@@ -47,6 +56,9 @@ beforeEach(() => {
})
vi.stubGlobal('window', {
api: {
repos: {
list: vi.fn()
},
projects: {
createHostSetup: projectsCreateHostSetup,
updateHostSetup: projectsUpdateHostSetup,
@@ -160,4 +172,55 @@ describe('repo slice project host setup lifecycle', () => {
timeoutMs: 15_000
})
})
it('preserves runtime-fetched setup-only states during repo hydration', async () => {
const pendingSetup: ProjectHostSetup = {
...runtimeSetup,
id: 'setup-pending',
repoId: '',
path: '',
setupState: 'setting-up'
}
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
if (args.method === 'repo.list') {
return {
id: 'rpc-repos',
ok: true,
result: { repos: [runtimeRepo] },
_meta: { runtimeId: 'runtime-remote' }
}
}
if (args.method === 'project.list') {
return {
id: 'rpc-projects',
ok: true,
result: { projects: [project] },
_meta: { runtimeId: 'runtime-remote' }
}
}
if (args.method === 'projectHostSetup.list') {
return {
id: 'rpc-setups',
ok: true,
result: { setups: [pendingSetup] },
_meta: { runtimeId: 'runtime-remote' }
}
}
throw new Error(`Unexpected runtime method: ${args.method}`)
})
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
await store.getState().fetchRepos()
expect(store.getState().projectHostSetups).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'setup-pending',
hostId: 'runtime:env-1',
setupState: 'setting-up'
})
])
)
})
})
+29 -1
View File
@@ -280,6 +280,31 @@ function projectCompatibilityFromRepos(
}
}
function mergeProjectHostSetupCompatibility(
derived: Pick<RepoSlice, 'projects' | 'projectHostSetups'>,
fetched: ProjectHostSetupProjection
): Pick<RepoSlice, 'projects' | 'projectHostSetups'> {
return {
projects: mergeById(derived.projects, fetched.projects),
projectHostSetups: mergeById(derived.projectHostSetups, fetched.setups)
}
}
function mergeById<T extends { id: string }>(base: readonly T[], overlay: readonly T[]): T[] {
const merged = [...base]
const indexById = new Map(merged.map((entry, index) => [entry.id, index]))
for (const entry of overlay) {
const index = indexById.get(entry.id)
if (index === undefined) {
indexById.set(entry.id, merged.length)
merged.push(entry)
} else {
merged[index] = entry
}
}
return merged
}
function mergeFetchedReposForHost(
previous: readonly Repo[],
fetched: Repo[],
@@ -580,7 +605,10 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
projects: fetchedProjectCompatibility.projects,
projectHostSetups: fetchedProjectCompatibility.setups
}
: projectCompatibilityFromRepos(reconciledRepos)
: mergeProjectHostSetupCompatibility(
projectCompatibilityFromRepos(reconciledRepos),
fetchedProjectCompatibility
)
return {
repos: reconciledRepos,
...projectCompatibility,
+1 -1
View File
@@ -19,7 +19,7 @@
export const RUNTIME_PROTOCOL_VERSION = 3
export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2
export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 3
export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2
export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const