mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 16:02:49 +00:00
Polish remote host add project flows
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
@@ -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: [],
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "コマンドテンプレート",
|
||||
|
||||
@@ -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": "명령 템플릿",
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user