diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index 80679d0278b..37e69cc4f17 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -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 diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index da9df7913df..98f34d59ac8 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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 => { + 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 } } ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 130a5d974c5..8a30bdf496a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { + 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?.() ?? [] } diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts new file mode 100644 index 00000000000..858f09d6eaf --- /dev/null +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -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) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 4c1562a580f..e5d269c2c68 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 93595292ba7..8bb44dea258 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 589216f42b8..5bf9c47c606 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -124,6 +124,8 @@ import type { Repo, ProjectGroup, ProjectHostSetup, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, ProjectGroupImportResult, ProjectGroupImportMode, ShellHydrationFailureReason, @@ -777,6 +779,9 @@ export type PreloadApi = { projects: { list: () => Promise listHostSetups: () => Promise + setupExistingFolder: ( + args: ProjectHostSetupExistingFolderArgs + ) => Promise } projectGroups: { list: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index a21382c30d2..4902918c858 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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: { diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index e50b1a55e40..8d1813a2bc8 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -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', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 2bec64f0147..6a896502162 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -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 addRepo: () => Promise addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise + setupProjectExistingFolder: ( + args: ProjectHostSetupExistingFolderArgs + ) => Promise addNonGitFolder: (path: string) => Promise scanNestedRepos: ( path: string, @@ -653,6 +658,53 @@ export const createRepoSlice: StateCreator = (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') { diff --git a/src/shared/types.ts b/src/shared/types.ts index 083257eabe3..2ae06b03fec 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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