Add project host setup existing folder API

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:33:58 -07:00
co-authored by Orca
parent 15a04d18a9
commit cbdd3618d1
11 changed files with 572 additions and 149 deletions
+8
View File
@@ -720,6 +720,12 @@ Landed so far:
fields to `Worktree`/`WorktreeMeta`, threaded them through worktree merge
paths, and stamped them for new local, SSH, folder, and runtime-created
workspaces when project-host setup data is available.
- Added first-class setup-on-existing-folder plumbing through local IPC,
preload, runtime RPC, runtime service, and the renderer repo slice. The new
`projectHostSetup.setupExistingFolder` path returns `{ project, setup, repo }`
and reuses the existing local/SSH/runtime repo import behavior underneath,
with an identity guard so an imported folder does not silently become a
different project.
- Added tests for local repos, SSH repos, same-provider multi-host grouping,
no-identity same-name non-grouping, selector cache behavior, persistence
backfill, repo mutation synchronization, renderer hydration, and runtime RPC
@@ -743,6 +749,8 @@ Remaining end-to-end work:
- teach workspace creation to resolve `{ projectId, hostId }` through a ready
project-host setup
- add setup-on-host flows for local paths and SSH paths
- add clone/provision setup-on-host flows; existing-folder setup now has an API
bridge, but not a complete user-facing project settings flow
- split settings into explicit client, host, project, and project-host setup
scopes
- backfill explicit workspace project/setup/host ownership for existing
+241 -134
View File
@@ -11,6 +11,8 @@ import type {
Repo,
ProjectGroup,
ProjectGroupImportResult,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
NestedRepoScanResult,
BaseRefDefaultResult,
SparsePreset
@@ -61,6 +63,8 @@ import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import type { RepoMethod } from '../../shared/telemetry-events'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
import { parseExecutionHostId } from '../../shared/execution-host'
// Why: `method` answers "which entry point did the user take?", not "what did
// they add?" — so the IPC the renderer invoked IS the method. We never send
@@ -83,6 +87,180 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void {
track('repo_added', { method, ...getCohortAtEmit() })
}
function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult {
const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
const project = store.getProjects().find((entry) => entry.id === setup.projectId)
if (!project) {
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
}
return { project, setup, repo }
}
function alignRepoWithRequestedProject(
store: Store,
repo: Repo,
projectId: string
): ProjectHostSetupResult {
let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
if (setup.projectId !== projectId) {
const project = store.getProjects().find((entry) => entry.id === projectId)
if (!project?.providerIdentity || project.providerIdentity.provider !== 'github') {
throw new Error('Imported folder does not match the selected project identity.')
}
// Why: setup-on-host is an explicit user action for this project. When the
// folder lacks upstream metadata but the selected project has provider
// identity, stamp that identity so compatibility projection can merge it.
const updated = store.updateRepo(repo.id, {
upstream: {
owner: project.providerIdentity.owner,
repo: project.providerIdentity.repo
}
})
if (!updated) {
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
}
repo = updated
setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
}
return buildProjectHostSetupResult(store, repo)
}
async function addLocalRepoFromPath(
store: Store,
path: string,
kind: 'git' | 'folder' = 'git'
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
const repoKind = kind === 'folder' ? 'folder' : 'git'
if (repoKind === 'git' && !isGitRepo(path)) {
return { error: `Not a valid git repository: ${path}` }
}
const existing = store.getRepos().find((r) => r.path === path)
if (existing) {
return { repo: existing, alreadyExisted: true }
}
const detected = await detectRepoIconAndUpstream({ repoPath: path, kind: repoKind })
const repo: Repo = {
id: randomUUID(),
path,
displayName: getRepoName(path),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false
}
: {})
}
store.addRepo(repo)
return { repo, alreadyExisted: false }
}
async function addRemoteRepoFromPath(
store: Store,
args: {
connectionId: string
remotePath: string
displayName?: string
kind?: 'git' | 'folder'
}
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
const gitProvider = getSshGitProvider(args.connectionId)
if (!gitProvider) {
return { error: `SSH connection "${args.connectionId}" not found or not connected` }
}
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
}
}
}
const existing = store
.getRepos()
.find((r) => r.connectionId === args.connectionId && r.path === resolvedPath)
if (existing) {
return { repo: existing, alreadyExisted: true }
}
if (args.kind !== 'folder') {
try {
const check = await gitProvider.isGitRepoAsync(resolvedPath)
if (check.isRepo) {
repoKind = 'git'
if (check.rootPath) {
resolvedPath = check.rootPath
}
} else {
return { error: `Not a valid git repository: ${args.remotePath}` }
}
} catch (err) {
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
return { error: err.message }
}
return { error: `Not a valid git repository: ${args.remotePath}` }
}
}
const folderName = getRemoteRepoFolderName(resolvedPath)
let displayName = args.displayName || folderName
if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) {
const sshTarget = store.getSshTarget(args.connectionId)
if (sshTarget) {
displayName = sshTarget.label
}
}
const detected = await detectRepoIconAndUpstream({
repoPath: resolvedPath,
kind: repoKind,
connectionId: args.connectionId
})
const repo: Repo = {
id: randomUUID(),
path: resolvedPath,
displayName,
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
connectionId: args.connectionId,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false
}
: {})
}
store.addRepo(repo)
const mux = getActiveMultiplexer(args.connectionId)
if (mux) {
mux.notify('session.registerRoot', { rootPath: resolvedPath })
}
return { repo, alreadyExisted: false }
}
function getRemoteRepoFolderName(remotePath: string): string {
const trimmed = remotePath.replace(/[\\/]+$/, '')
if (!trimmed) {
@@ -146,6 +324,14 @@ const ProjectGroupMoveProjectArgs = z.object({
order: z.number().finite().optional()
})
const ProjectHostSetupExistingFolderIpcArgs = z.object({
projectId: z.string().min(1),
hostId: z.string().min(1),
path: z.string().min(1),
kind: z.enum(['git', 'folder']).optional(),
displayName: z.string().min(1).optional()
})
const ProjectGroupScanNestedArgs = z.object({
path: z.string().min(1),
connectionId: z.string().min(1).optional(),
@@ -434,6 +620,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('repos:update')
ipcMain.removeHandler('projects:list')
ipcMain.removeHandler('projectHostSetups:list')
ipcMain.removeHandler('projectHostSetups:setupExistingFolder')
ipcMain.removeHandler('projectGroups:list')
ipcMain.removeHandler('projectGroups:create')
ipcMain.removeHandler('projectGroups:update')
@@ -464,6 +651,50 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups())
ipcMain.handle(
'projectHostSetups:setupExistingFolder',
async (
_event,
rawArgs: ProjectHostSetupExistingFolderArgs
): Promise<ProjectHostSetupResult> => {
const args = parseProjectGroupIpcArgs(
ProjectHostSetupExistingFolderIpcArgs,
rawArgs,
'project_host_setup_invalid_args'
)
const parsedHost = parseExecutionHostId(args.hostId)
if (!parsedHost) {
throw new Error(`Unsupported host: ${args.hostId}`)
}
const existingProject = store.getProjects().find((project) => project.id === args.projectId)
if (!existingProject) {
throw new Error(`Project not found: ${args.projectId}`)
}
const result =
parsedHost.kind === 'local'
? await addLocalRepoFromPath(store, args.path, args.kind)
: parsedHost.kind === 'ssh'
? await addRemoteRepoFromPath(store, {
connectionId: parsedHost.targetId,
remotePath: args.path,
displayName: args.displayName,
kind: args.kind
})
: {
error:
'Runtime hosts must be set up through the runtime projectHostSetup.setupExistingFolder RPC.'
}
if ('error' in result) {
throw new Error(result.error)
}
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', result.alreadyExisted)
return alignRepoWithRequestedProject(store, result.repo, args.projectId)
}
)
ipcMain.handle('projectGroups:list', () => store.getProjectGroups())
ipcMain.handle('projectGroups:create', (_event, rawArgs: unknown): ProjectGroup => {
@@ -678,40 +909,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
_event,
args: { path: string; kind?: 'git' | 'folder' }
): Promise<{ repo: Repo } | { error: string }> => {
const repoKind = args.kind === 'folder' ? 'folder' : 'git'
if (repoKind === 'git' && !isGitRepo(args.path)) {
return { error: `Not a valid git repository: ${args.path}` }
const result = await addLocalRepoFromPath(store, args.path, args.kind)
if ('error' in result) {
return result
}
// Check if already added
const existing = store.getRepos().find((r) => r.path === args.path)
if (existing) {
emitRepoAdded('folder_picker', true)
return { repo: existing }
}
const detected = await detectRepoIconAndUpstream({ repoPath: args.path, kind: repoKind })
const repo: Repo = {
id: randomUUID(),
path: args.path,
displayName: getRepoName(args.path),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false
}
: {})
}
store.addRepo(repo)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', false)
return { repo }
emitRepoAdded('folder_picker', result.alreadyExisted)
return { repo: result.repo }
}
)
@@ -726,111 +931,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
kind?: 'git' | 'folder'
}
): Promise<{ repo: Repo } | { error: string }> => {
const gitProvider = getSshGitProvider(args.connectionId)
if (!gitProvider) {
return { error: `SSH connection "${args.connectionId}" not found or not connected` }
const result = await addRemoteRepoFromPath(store, args)
if ('error' in result) {
return result
}
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
}
}
}
// Why: check for duplicates after tilde resolution so that adding `~/`
// when `/home/ubuntu` is already stored correctly detects the duplicate.
const existing = store
.getRepos()
.find((r) => r.connectionId === args.connectionId && r.path === resolvedPath)
if (existing) {
emitRepoAdded('folder_picker', true)
return { repo: existing }
}
if (args.kind !== 'folder') {
// Why: when kind is not explicitly 'folder', verify the remote path is
// a git repo. Return an error on failure so the renderer can show the "Open as
// Folder" confirmation dialog — matching the local add-repo behavior
// where non-git directories require explicit user consent.
try {
const check = await gitProvider.isGitRepoAsync(resolvedPath)
if (check.isRepo) {
repoKind = 'git'
if (check.rootPath) {
resolvedPath = check.rootPath
}
} else {
return { error: `Not a valid git repository: ${args.remotePath}` }
}
} catch (err) {
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
return { error: err.message }
}
return { error: `Not a valid git repository: ${args.remotePath}` }
}
}
const folderName = getRemoteRepoFolderName(resolvedPath)
// When folderName is the home directory basename (e.g. 'ubuntu'),
// use SSH target label for a more descriptive name
let displayName = args.displayName || folderName
if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) {
const sshTarget = store.getSshTarget(args.connectionId)
if (sshTarget) {
displayName = sshTarget.label
}
}
const detected = await detectRepoIconAndUpstream({
repoPath: resolvedPath,
kind: repoKind,
connectionId: args.connectionId
})
const repo: Repo = {
id: randomUUID(),
path: resolvedPath,
displayName,
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
kind: repoKind,
connectionId: args.connectionId,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false
}
: {})
}
store.addRepo(repo)
notifyReposChanged(mainWindow)
// Why: register the workspace root with the relay so mutating FS operations
// are scoped to this repo's path. Without this, the relay's path ACL would
// reject writes to the workspace after the first root is registered.
const mux = getActiveMultiplexer(args.connectionId)
if (mux) {
mux.notify('session.registerRoot', { rootPath: resolvedPath })
}
emitRepoAdded('folder_picker', false)
return { repo }
emitRepoAdded('folder_picker', result.alreadyExisted)
return { repo: result.repo }
}
)
+44 -1
View File
@@ -52,6 +52,8 @@ import type {
PersistedUIState,
Project,
ProjectHostSetup,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
Repo,
RemoveWorktreeResult,
StatsSummary,
@@ -87,7 +89,10 @@ import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-even
import type { FeatureInteractionId } from '../../shared/feature-interactions'
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
import {
getProjectHostSetupForRepo,
getProjectHostSetupWorktreeMeta
} from '../../shared/project-host-setup-projection'
import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits'
import { isFolderRepo } from '../../shared/repo-kind'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
@@ -6448,6 +6453,44 @@ export class OrcaRuntimeService {
return this.store?.getProjectHostSetups?.() ?? []
}
async setupProjectExistingFolder(
args: ProjectHostSetupExistingFolderArgs
): Promise<ProjectHostSetupResult> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
const existingProject = this.listProjects().find((project) => project.id === args.projectId)
if (!existingProject) {
throw new Error(`Project not found: ${args.projectId}`)
}
let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git')
let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
if (setup.projectId !== args.projectId) {
if (
!existingProject.providerIdentity ||
existingProject.providerIdentity.provider !== 'github'
) {
throw new Error('Imported folder does not match the selected project identity.')
}
const updated = this.store.updateRepo(repo.id, {
upstream: {
owner: existingProject.providerIdentity.owner,
repo: existingProject.providerIdentity.repo
}
})
if (!updated) {
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
}
repo = updated
setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
}
const project = this.listProjects().find((entry) => entry.id === setup.projectId)
if (!project) {
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
}
return { project, setup, repo }
}
listProjectGroups(): ProjectGroup[] {
return this.store?.getProjectGroups?.() ?? []
}
@@ -0,0 +1,39 @@
import { z } from 'zod'
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalString, requiredString } from '../schemas'
const ProjectHostSetupExistingFolder = z.object({
projectId: requiredString('Missing project ID'),
hostId: requiredString('Missing host ID').transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
return z.NEVER
}
return hostId
}),
path: requiredString('Missing project path'),
kind: z.enum(['git', 'folder']).optional(),
displayName: OptionalString
})
export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
defineMethod({
name: 'project.list',
params: null,
handler: (_params, { runtime }) => ({ projects: runtime.listProjects() })
}),
defineMethod({
name: 'projectHostSetup.list',
params: null,
handler: (_params, { runtime }) => ({ setups: runtime.listProjectHostSetups() })
}),
defineMethod({
name: 'projectHostSetup.setupExistingFolder',
params: ProjectHostSetupExistingFolder,
handler: async (params, { runtime }) => ({
result: await runtime.setupProjectExistingFolder(params)
})
})
]
+48
View File
@@ -353,6 +353,54 @@ describe('repo RPC methods', () => {
expect(runtime.listProjectHostSetups).toHaveBeenCalled()
})
it('routes project-host setup import to the runtime server', async () => {
const result = {
project: {
id: 'project-1',
displayName: 'Project',
badgeColor: '#737373',
sourceRepoIds: ['repo-1'],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'repo-1',
projectId: 'project-1',
hostId: 'local',
repoId: 'repo-1',
path: '/repo',
displayName: 'Project',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
},
repo: { id: 'repo-1', path: '/repo', displayName: 'Project' }
}
const runtime = {
getRuntimeId: () => 'test-runtime',
setupProjectExistingFolder: vi.fn().mockResolvedValue(result)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('projectHostSetup.setupExistingFolder', {
projectId: 'project-1',
hostId: 'runtime:env-1',
path: '/repo',
kind: 'git'
})
)
expect(runtime.setupProjectExistingFolder).toHaveBeenCalledWith({
projectId: 'project-1',
hostId: 'runtime:env-1',
path: '/repo',
kind: 'git'
})
expect(response).toMatchObject({ ok: true, result: { result } })
})
it('allows separate nested-repo imports without a group name', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+2 -10
View File
@@ -4,6 +4,7 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
const RepoSelector = z.object({
repo: requiredString('Missing repo selector')
@@ -156,16 +157,7 @@ export const REPO_METHODS: RpcMethod[] = [
params: null,
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
}),
defineMethod({
name: 'project.list',
params: null,
handler: (_params, { runtime }) => ({ projects: runtime.listProjects() })
}),
defineMethod({
name: 'projectHostSetup.list',
params: null,
handler: (_params, { runtime }) => ({ setups: runtime.listProjectHostSetups() })
}),
...PROJECT_RUNTIME_METHODS,
defineMethod({
name: 'projectGroup.list',
params: null,
+5
View File
@@ -124,6 +124,8 @@ import type {
Repo,
ProjectGroup,
ProjectHostSetup,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
ProjectGroupImportResult,
ProjectGroupImportMode,
ShellHydrationFailureReason,
@@ -777,6 +779,9 @@ export type PreloadApi = {
projects: {
list: () => Promise<Project[]>
listHostSetups: () => Promise<ProjectHostSetup[]>
setupExistingFolder: (
args: ProjectHostSetupExistingFolderArgs
) => Promise<ProjectHostSetupResult>
}
projectGroups: {
list: () => Promise<ProjectGroup[]>
+2 -1
View File
@@ -505,7 +505,8 @@ const api = {
projects: {
list: () => ipcRenderer.invoke('projects:list'),
listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list')
listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'),
setupExistingFolder: (args) => ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args)
} satisfies PreloadApi['projects'],
projectGroups: {
+116 -2
View File
@@ -39,6 +39,7 @@ const reposPickFolder = vi.fn()
const reposRemove = vi.fn()
const reposUpdate = vi.fn()
const reposReorder = vi.fn()
const projectsSetupExistingFolder = vi.fn()
const projectGroupsMoveProject = vi.fn()
const ptyKill = vi.fn()
const runtimeEnvironmentCall = vi.fn()
@@ -52,6 +53,7 @@ beforeEach(() => {
reposRemove.mockReset()
reposUpdate.mockReset()
reposReorder.mockReset()
projectsSetupExistingFolder.mockReset()
projectGroupsMoveProject.mockReset()
ptyKill.mockReset()
runtimeEnvironmentCall.mockReset()
@@ -69,6 +71,9 @@ beforeEach(() => {
update: reposUpdate,
reorder: reposReorder
},
projects: {
setupExistingFolder: projectsSetupExistingFolder
},
projectGroups: {
moveProject: projectGroupsMoveProject
},
@@ -121,11 +126,16 @@ describe('repo slice runtime routing', () => {
const listHostSetups = vi.fn().mockResolvedValue([setup])
;(
window.api as typeof window.api & {
projects?: { list: typeof projectsList; listHostSetups: typeof listHostSetups }
projects?: {
list: typeof projectsList
listHostSetups: typeof listHostSetups
setupExistingFolder: typeof projectsSetupExistingFolder
}
}
).projects = {
list: projectsList,
listHostSetups
listHostSetups,
setupExistingFolder: projectsSetupExistingFolder
}
reposList.mockResolvedValue([localRepo])
const store = createTestStore()
@@ -244,6 +254,110 @@ describe('repo slice runtime routing', () => {
expect(reposPickFolder).not.toHaveBeenCalled()
})
it('sets up a project on a local host through the project setup API', async () => {
const project: Project = {
id: 'project-1',
displayName: 'Project',
badgeColor: '#000',
sourceRepoIds: ['local-repo'],
createdAt: 1,
updatedAt: 1
}
const setup: ProjectHostSetup = {
id: 'local-repo',
projectId: project.id,
hostId: 'local',
repoId: 'local-repo',
path: '/local',
displayName: 'Local',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: localRepo })
const store = createTestStore()
await expect(
store.getState().setupProjectExistingFolder({
projectId: project.id,
hostId: 'local',
path: '/local',
kind: 'git'
})
).resolves.toEqual({
project,
setup,
repo: { ...localRepo, executionHostId: 'local' }
})
expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }])
expect(store.getState().projects).toEqual([project])
expect(store.getState().projectHostSetups).toEqual([setup])
expect(projectsSetupExistingFolder).toHaveBeenCalledWith({
projectId: project.id,
hostId: 'local',
path: '/local',
kind: 'git'
})
})
it('sets up a project on the active runtime host through runtime RPC', async () => {
const project: Project = {
id: 'project-1',
displayName: 'Project',
badgeColor: '#000',
sourceRepoIds: ['remote-repo'],
createdAt: 1,
updatedAt: 1
}
const setup: ProjectHostSetup = {
id: 'remote-repo',
projectId: project.id,
hostId: 'local',
repoId: 'remote-repo',
path: '/srv/project',
displayName: 'Remote',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-setup',
ok: true,
result: { result: { project, setup, repo: remoteRepo } },
_meta: { runtimeId: 'runtime-remote' }
})
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
await expect(
store.getState().setupProjectExistingFolder({
projectId: project.id,
hostId: 'runtime:env-1',
path: '/srv/project',
kind: 'git'
})
).resolves.toEqual({
project,
setup: { ...setup, hostId: 'runtime:env-1', executionHostId: 'runtime:env-1' },
repo: { ...remoteRepo, executionHostId: 'runtime:env-1' }
})
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'projectHostSetup.setupExistingFolder',
params: {
projectId: project.id,
hostId: 'runtime:env-1',
path: '/srv/project',
kind: 'git'
},
timeoutMs: 15_000
})
})
it('keeps runtime ownership when a runtime repo is moved between groups', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-move',
+53 -1
View File
@@ -11,7 +11,9 @@ import type {
ProjectGroup,
ProjectHostSetup,
ProjectGroupImportResult,
NestedRepoScanResult
NestedRepoScanResult,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult
} from '../../../../shared/types'
import {
projectHostSetupProjectionFromRepos,
@@ -264,6 +266,9 @@ export type RepoSlice = {
fetchProjectGroups: () => Promise<void>
addRepo: () => Promise<Repo | null>
addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null>
setupProjectExistingFolder: (
args: ProjectHostSetupExistingFolderArgs
) => Promise<ProjectHostSetupResult | null>
addNonGitFolder: (path: string) => Promise<Repo | null>
scanNestedRepos: (
path: string,
@@ -653,6 +658,53 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
setupProjectExistingFolder: async (args) => {
try {
const target = getActiveRuntimeTarget(get().settings)
const result =
target.kind === 'local'
? await window.api.projects.setupExistingFolder(args)
: (
await callRuntimeRpc<{ result: ProjectHostSetupResult }>(
target,
'projectHostSetup.setupExistingFolder',
args,
{ timeoutMs: 15_000 }
)
).result
const repo = repoWithFetchedOwner(result.repo, target)
const setup = setupWithFetchedOwner(result.setup, target)
set((s) => {
const nextRepos = s.repos.some((entry) => entry.id === repo.id)
? s.repos.map((entry) => (entry.id === repo.id ? repo : entry))
: [...s.repos, repo]
const nextProjects = s.projects.some((entry) => entry.id === result.project.id)
? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry))
: [...s.projects, result.project]
const nextSetups = s.projectHostSetups.some((entry) => entry.id === setup.id)
? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry))
: [...s.projectHostSetups, setup]
return {
repos: nextRepos,
projects: nextProjects,
projectHostSetups: nextSetups
}
})
toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), {
description: repo.displayName
})
return { ...result, repo, setup }
} catch (err) {
console.error('Failed to set up project on host:', err)
const message = err instanceof Error ? err.message : String(err)
toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), {
description: message,
duration: ERROR_TOAST_DURATION
})
return null
}
},
addRepo: async () => {
const target = getActiveRuntimeTarget(get().settings)
if (target.kind !== 'local') {
+14
View File
@@ -125,6 +125,20 @@ export type ProjectHostSetup = {
updatedAt: number
}
export type ProjectHostSetupExistingFolderArgs = {
projectId: string
hostId: ExecutionHostId
path: string
kind?: RepoKind
displayName?: string
}
export type ProjectHostSetupResult = {
project: Project
setup: ProjectHostSetup
repo: Repo
}
export type Repo = {
id: string
path: string