mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Merge latest origin/main into brennanb2025/native-chat-multiline-send
This commit is contained in:
@@ -1115,6 +1115,7 @@ describe('repos:addRemote', () => {
|
||||
})
|
||||
mockStore.getRepos.mockReset().mockReturnValue([])
|
||||
mockStore.addRepo.mockReset()
|
||||
mockStore.removeProject.mockReset()
|
||||
mockStore.getSshTarget.mockReset()
|
||||
mockStore.updateRepo.mockReset()
|
||||
mockGitProvider.isGitRepoAsync.mockReset()
|
||||
@@ -2066,6 +2067,76 @@ describe('repos:add + repos:clone', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sets up a folder when the selected project exists only on another host', async () => {
|
||||
const added: Record<string, unknown>[] = []
|
||||
mockStore.getRepos.mockImplementation(() => added)
|
||||
mockStore.addRepo.mockImplementation((repo: Record<string, unknown>) => added.push(repo))
|
||||
mockStore.updateRepo.mockImplementation((id, updates) => {
|
||||
const repo = added.find((entry) => entry.id === id)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
Object.assign(repo, updates)
|
||||
return { ...repo }
|
||||
})
|
||||
mockStore.getProjects.mockImplementation(() => {
|
||||
const repo = added.find((entry) => 'upstream' in entry)
|
||||
return repo
|
||||
? [
|
||||
{
|
||||
id: 'github:github.acme.test/acme/orca',
|
||||
displayName: 'Orca',
|
||||
providerIdentity: {
|
||||
provider: 'github',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
|
||||
const result = await handlers.get('projectHostSetups:setupExistingFolder')!(null, {
|
||||
projectId: 'github:github.acme.test/acme/orca',
|
||||
projectProviderIdentity: {
|
||||
provider: 'github',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
},
|
||||
hostId: 'local',
|
||||
path: '/tmp/orca-local',
|
||||
kind: 'git'
|
||||
})
|
||||
|
||||
expect(added[0]?.upstream).toEqual({
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
})
|
||||
expect(result).toHaveProperty('project.id', 'github:github.acme.test/acme/orca')
|
||||
})
|
||||
|
||||
it('rolls back a new repo when the supplied identity does not match the project', async () => {
|
||||
const added: Record<string, unknown>[] = []
|
||||
mockStore.getRepos.mockImplementation(() => added)
|
||||
mockStore.addRepo.mockImplementation((repo: Record<string, unknown>) => added.push(repo))
|
||||
|
||||
await expect(
|
||||
handlers.get('projectHostSetups:setupExistingFolder')!(null, {
|
||||
projectId: 'github:acme/orca',
|
||||
projectProviderIdentity: { provider: 'github', owner: 'other', repo: 'orca' },
|
||||
hostId: 'local',
|
||||
path: '/tmp/mismatched-project',
|
||||
kind: 'git'
|
||||
})
|
||||
).rejects.toThrow('Imported folder does not match the selected project identity.')
|
||||
|
||||
expect(mockStore.removeProject).toHaveBeenCalledWith(added[0]?.id)
|
||||
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prepares and invalidates roots when repos:update changes worktree base path', () => {
|
||||
const updated = {
|
||||
id: 'repo-update-root',
|
||||
|
||||
+37
-17
@@ -89,7 +89,10 @@ import type {
|
||||
} from '../../shared/host-repo-catalog-contract'
|
||||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
|
||||
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
|
||||
import {
|
||||
getProjectHostSetupForRepo,
|
||||
getProjectIdForProviderIdentity
|
||||
} from '../../shared/project-host-setup-projection'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
@@ -235,20 +238,23 @@ function alignRepoWithRequestedProject(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
projectId: string,
|
||||
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder'
|
||||
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder',
|
||||
requestedProviderIdentity?: ProjectHostSetupExistingFolderArgs['projectProviderIdentity']
|
||||
): 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') {
|
||||
// Why: the selected project can exist only on the source host, so its structured identity travels with the request.
|
||||
const identity = project?.providerIdentity ?? requestedProviderIdentity
|
||||
if (!identity || getProjectIdForProviderIdentity(identity) !== projectId) {
|
||||
throw new Error('Imported folder does not match the selected project identity.')
|
||||
}
|
||||
// Why: stamp the selected project's provider identity when the folder lacks upstream, so projection can merge it.
|
||||
const updated = store.updateRepo(repo.id, {
|
||||
upstream: {
|
||||
owner: project.providerIdentity.owner,
|
||||
repo: project.providerIdentity.repo,
|
||||
...(project.providerIdentity.host ? { host: project.providerIdentity.host } : {})
|
||||
owner: identity.owner,
|
||||
repo: identity.repo,
|
||||
...(identity.host ? { host: identity.host } : {})
|
||||
}
|
||||
})
|
||||
if (!updated) {
|
||||
@@ -801,6 +807,14 @@ const ProjectGroupMoveProjectArgs = z.object({
|
||||
|
||||
const ProjectHostSetupExistingFolderIpcArgs = z.object({
|
||||
projectId: z.string().min(1),
|
||||
projectProviderIdentity: z
|
||||
.object({
|
||||
provider: z.literal('github'),
|
||||
owner: z.string().min(1),
|
||||
repo: z.string().min(1),
|
||||
host: z.string().min(1).optional()
|
||||
})
|
||||
.optional(),
|
||||
hostId: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
kind: z.enum(['git', 'folder']).optional(),
|
||||
@@ -1378,11 +1392,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
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)
|
||||
@@ -1400,15 +1409,26 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
let aligned: ProjectHostSetupResult
|
||||
try {
|
||||
aligned = alignRepoWithRequestedProject(
|
||||
store,
|
||||
result.repo,
|
||||
args.projectId,
|
||||
args.setupMethod,
|
||||
args.projectProviderIdentity
|
||||
)
|
||||
} catch (err) {
|
||||
// Why: an import that cannot be linked must not leave a new repo registration or authorization root behind.
|
||||
if (!result.alreadyExisted) {
|
||||
store.removeProject(result.repo.id)
|
||||
invalidateAuthorizedRootsCache()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted)
|
||||
const aligned = alignRepoWithRequestedProject(
|
||||
store,
|
||||
result.repo,
|
||||
args.projectId,
|
||||
args.setupMethod
|
||||
)
|
||||
if (result.alreadyExisted) {
|
||||
await prepareLocalWorktreeRootForRepo(store, aligned.repo)
|
||||
}
|
||||
|
||||
@@ -6955,6 +6955,145 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('sets up a project whose identity exists only on the requesting host', async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-cross-host-project-'))
|
||||
const repos: Record<string, unknown>[] = []
|
||||
getRepoUpstreamMock.mockResolvedValueOnce(null)
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [...repos] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => repos.push(repo),
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
|
||||
updateRepo: (id: string, updates: Record<string, unknown>) => {
|
||||
const repo = repos.find((entry) => entry.id === id)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
Object.assign(repo, updates)
|
||||
return { ...repo } as never
|
||||
},
|
||||
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
|
||||
getProjectHostSetups: () =>
|
||||
projectHostSetupProjectionFromRepos(repos as never).setups as never
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: tempRoot, stdio: 'ignore' })
|
||||
const result = await runtime.setupProjectExistingFolder({
|
||||
projectId: 'github:github.acme.test/acme/orca',
|
||||
projectProviderIdentity: {
|
||||
provider: 'github',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
},
|
||||
hostId: 'runtime:env-1',
|
||||
path: tempRoot,
|
||||
kind: 'git'
|
||||
})
|
||||
|
||||
expect(result.project).toMatchObject({
|
||||
id: 'github:github.acme.test/acme/orca',
|
||||
providerIdentity: {
|
||||
provider: 'github',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back a new runtime repo when project alignment fails', async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-rollback-'))
|
||||
const repos: Record<string, unknown>[] = []
|
||||
getRepoUpstreamMock.mockResolvedValueOnce(null)
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [...repos] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => repos.push(repo),
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id) as never,
|
||||
updateRepo: (id: string, updates: Record<string, unknown>) => {
|
||||
const repo = repos.find((entry) => entry.id === id)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
Object.assign(repo, updates)
|
||||
return { ...repo } as never
|
||||
},
|
||||
removeProject: (id: string) => {
|
||||
const index = repos.findIndex((repo) => repo.id === id)
|
||||
if (index !== -1) {
|
||||
repos.splice(index, 1)
|
||||
}
|
||||
},
|
||||
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
|
||||
getProjectHostSetups: () =>
|
||||
projectHostSetupProjectionFromRepos(repos as never).setups as never
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: tempRoot, stdio: 'ignore' })
|
||||
await expect(
|
||||
runtime.setupProjectExistingFolder({
|
||||
projectId: 'git:git.example.test/acme/orca',
|
||||
hostId: 'runtime:env-1',
|
||||
path: tempRoot,
|
||||
kind: 'git'
|
||||
})
|
||||
).rejects.toThrow('Imported folder does not match the selected project identity.')
|
||||
|
||||
expect(repos).toHaveLength(0)
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back a newly cloned repo when project alignment fails', async () => {
|
||||
const repos: Record<string, unknown>[] = []
|
||||
const clonedRepo = {
|
||||
id: 'cloned-repo',
|
||||
path: '/tmp/cloned-repo',
|
||||
displayName: 'cloned-repo',
|
||||
badgeColor: '#737373',
|
||||
addedAt: 1,
|
||||
kind: 'git'
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [...repos] as never,
|
||||
removeProject: (id: string) => {
|
||||
const index = repos.findIndex((repo) => repo.id === id)
|
||||
if (index !== -1) {
|
||||
repos.splice(index, 1)
|
||||
}
|
||||
},
|
||||
getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never,
|
||||
getProjectHostSetups: () =>
|
||||
projectHostSetupProjectionFromRepos(repos as never).setups as never
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
vi.spyOn(runtime, 'cloneRepo').mockImplementation(async () => {
|
||||
repos.push(clonedRepo)
|
||||
return clonedRepo as never
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.setupProjectClone({
|
||||
projectId: 'git:git.example.test/acme/orca',
|
||||
hostId: 'runtime:env-1',
|
||||
url: 'https://git.example.test/acme/orca.git',
|
||||
destination: '/tmp'
|
||||
})
|
||||
).rejects.toThrow('Imported folder does not match the selected project identity.')
|
||||
|
||||
expect(repos).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps existing-folder imports split by runtime host on the same normalized path', async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-host-'))
|
||||
const repos: Record<string, unknown>[] = []
|
||||
|
||||
@@ -346,6 +346,7 @@ import {
|
||||
splitWorktreeIdForFilesystem
|
||||
} from '../../shared/worktree-id'
|
||||
import {
|
||||
getProjectIdForProviderIdentity,
|
||||
getProjectHostSetupForRepo,
|
||||
getProjectHostSetupWorktreeMeta
|
||||
} from '../../shared/project-host-setup-projection'
|
||||
@@ -16702,23 +16703,68 @@ export class OrcaRuntimeService {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
assertProjectHostSetupHostIsSupported(args.hostId)
|
||||
let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId)
|
||||
const knownRepoIds = new Set(this.listRepos().map((repo) => repo.id))
|
||||
const repo = await this.addRepo(
|
||||
args.path,
|
||||
args.kind === 'folder' ? 'folder' : 'git',
|
||||
args.hostId
|
||||
)
|
||||
return this.completeProjectHostSetup(args, repo, !knownRepoIds.has(repo.id))
|
||||
}
|
||||
|
||||
async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> {
|
||||
// Why: guard before cloneRepo, which would otherwise clone to the local disk.
|
||||
assertProjectHostSetupHostIsSupported(args.hostId)
|
||||
const knownRepoIds = new Set(this.listRepos().map((repo) => repo.id))
|
||||
const repo = await this.cloneRepo(args.url, args.destination, args.hostId)
|
||||
return this.completeProjectHostSetup(
|
||||
{ ...args, path: repo.path, kind: 'git', setupMethod: 'cloned' },
|
||||
repo,
|
||||
!knownRepoIds.has(repo.id)
|
||||
)
|
||||
}
|
||||
|
||||
private completeProjectHostSetup(
|
||||
args: ProjectHostSetupExistingFolderArgs,
|
||||
initialRepo: Repo,
|
||||
repoWasCreated: boolean
|
||||
): ProjectHostSetupResult {
|
||||
try {
|
||||
return this.linkRepoToProjectHostSetup(args, initialRepo)
|
||||
} catch (err) {
|
||||
if (repoWasCreated) {
|
||||
// Why: a failed link must not leave a new repo registration or stale host caches behind.
|
||||
this.store?.removeProject?.(initialRepo.id)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(initialRepo.id)
|
||||
invalidateAuthorizedRootsCache()
|
||||
this.notifyReposChanged()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private linkRepoToProjectHostSetup(
|
||||
args: ProjectHostSetupExistingFolderArgs,
|
||||
initialRepo: Repo
|
||||
): ProjectHostSetupResult {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
let repo = initialRepo
|
||||
let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo)
|
||||
if (setup.projectId !== args.projectId) {
|
||||
const existingProject = this.listProjects().find((project) => project.id === args.projectId)
|
||||
if (
|
||||
!existingProject?.providerIdentity ||
|
||||
existingProject.providerIdentity.provider !== 'github'
|
||||
) {
|
||||
// Why: the selected project can exist only on the source host, so its structured identity travels with the request.
|
||||
const identity = existingProject?.providerIdentity ?? args.projectProviderIdentity
|
||||
if (!identity || getProjectIdForProviderIdentity(identity) !== args.projectId) {
|
||||
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,
|
||||
...(existingProject.providerIdentity.host
|
||||
? { host: existingProject.providerIdentity.host }
|
||||
: {})
|
||||
owner: identity.owner,
|
||||
repo: identity.repo,
|
||||
...(identity.host ? { host: identity.host } : {})
|
||||
}
|
||||
})
|
||||
if (!updated) {
|
||||
@@ -16743,20 +16789,6 @@ export class OrcaRuntimeService {
|
||||
return { project, setup, repo }
|
||||
}
|
||||
|
||||
async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise<ProjectHostSetupResult> {
|
||||
// Why: guard before cloneRepo, which would otherwise clone to the local disk.
|
||||
assertProjectHostSetupHostIsSupported(args.hostId)
|
||||
const repo = await this.cloneRepo(args.url, args.destination, args.hostId)
|
||||
return await this.setupProjectExistingFolder({
|
||||
projectId: args.projectId,
|
||||
hostId: args.hostId,
|
||||
path: repo.path,
|
||||
kind: 'git',
|
||||
displayName: args.displayName,
|
||||
setupMethod: 'cloned'
|
||||
})
|
||||
}
|
||||
|
||||
updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult {
|
||||
if (!this.store?.updateProjectHostSetup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
|
||||
@@ -3,8 +3,16 @@ import { normalizeExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalString, requiredString } from '../schemas'
|
||||
|
||||
const ProjectProviderIdentity = z.object({
|
||||
provider: z.literal('github'),
|
||||
owner: requiredString('Missing project owner'),
|
||||
repo: requiredString('Missing project repository'),
|
||||
host: OptionalString
|
||||
})
|
||||
|
||||
const ProjectHostSetupExistingFolder = z.object({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
projectProviderIdentity: ProjectProviderIdentity.optional(),
|
||||
hostId: requiredString('Missing host ID').transform((value, ctx) => {
|
||||
const hostId = normalizeExecutionHostId(value)
|
||||
if (!hostId) {
|
||||
@@ -21,6 +29,7 @@ const ProjectHostSetupExistingFolder = z.object({
|
||||
|
||||
const ProjectHostSetupClone = z.object({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
projectProviderIdentity: ProjectProviderIdentity.optional(),
|
||||
hostId: requiredString('Missing host ID').transform((value, ctx) => {
|
||||
const hostId = normalizeExecutionHostId(value)
|
||||
if (!hostId) {
|
||||
|
||||
@@ -47,10 +47,7 @@ import {
|
||||
buildAddProjectFromFolderModalData,
|
||||
canShowAddAsProjectAction
|
||||
} from './file-explorer-add-project-action'
|
||||
import {
|
||||
isRenameHotspotTarget,
|
||||
resolveDirToggleTiming
|
||||
} from './file-explorer-dir-toggle-timing'
|
||||
import { isRenameHotspotTarget, resolveDirToggleTiming } from './file-explorer-dir-toggle-timing'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { useFileExplorerSelection } from './useFileExplorerSelection'
|
||||
import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRowProjection'
|
||||
|
||||
+1
-6
@@ -37,12 +37,7 @@ function renderRenameRow(): {
|
||||
const onSubmit = vi.fn()
|
||||
const view = render(
|
||||
<div>
|
||||
<InlineInputRow
|
||||
depth={1}
|
||||
inlineInput={renameInput}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
<InlineInputRow depth={1} inlineInput={renameInput} onSubmit={onSubmit} onCancel={vi.fn()} />
|
||||
{/* Rows are Radix context-menu triggers, so a genuine click on a neighbour
|
||||
used to be indistinguishable from Radix restoring focus after a close. */}
|
||||
<button type="button" data-slot="context-menu-trigger">
|
||||
|
||||
@@ -35,15 +35,15 @@ describe('resolveCycledWorktreeId', () => {
|
||||
expect(
|
||||
resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: 'hidden', direction: 'up' })
|
||||
).toBe('c')
|
||||
expect(resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: null, direction: 'down' })).toBe(
|
||||
'a'
|
||||
)
|
||||
expect(
|
||||
resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: null, direction: 'down' })
|
||||
).toBe('a')
|
||||
})
|
||||
|
||||
it('has nothing to cycle to when every group is collapsed', () => {
|
||||
expect(resolveCycledWorktreeId({ worktreeIds: [], activeWorktreeId: 'a', direction: 'down' })).toBe(
|
||||
null
|
||||
)
|
||||
expect(
|
||||
resolveCycledWorktreeId({ worktreeIds: [], activeWorktreeId: 'a', direction: 'down' })
|
||||
).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -131,7 +131,10 @@ describe('getCyclableWorktreeIds', () => {
|
||||
|
||||
describe('WorktreeList keyboard cycling', () => {
|
||||
it('cycles over the rendered rows instead of rebuilding a parallel layout', () => {
|
||||
const source = readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8')
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const navigateWorktree = source.slice(
|
||||
source.indexOf('const navigateWorktree = useCallback('),
|
||||
source.indexOf('const handleContainerKeyDown = useCallback(')
|
||||
|
||||
@@ -210,9 +210,10 @@ describe('repo slice runtime routing', () => {
|
||||
|
||||
it('sets up a project on a local host through the project setup API', async () => {
|
||||
const project: Project = {
|
||||
id: 'project-1',
|
||||
id: 'github:stablyai/orca',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#000',
|
||||
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' },
|
||||
sourceRepoIds: ['local-repo'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
@@ -231,6 +232,7 @@ describe('repo slice runtime routing', () => {
|
||||
}
|
||||
projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: localRepo })
|
||||
const store = createTestStore()
|
||||
store.setState({ projects: [project] })
|
||||
|
||||
await expect(
|
||||
store.getState().setupProjectExistingFolder({
|
||||
@@ -250,6 +252,7 @@ describe('repo slice runtime routing', () => {
|
||||
expect(store.getState().projectHostSetups).toEqual([setup])
|
||||
expect(projectsSetupExistingFolder).toHaveBeenCalledWith({
|
||||
projectId: project.id,
|
||||
projectProviderIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' },
|
||||
hostId: 'local',
|
||||
path: '/local',
|
||||
kind: 'git'
|
||||
|
||||
@@ -2642,14 +2642,19 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
try {
|
||||
const target = getProjectSetupRuntimeTarget(args.hostId)
|
||||
await assertProjectHostSetupMutationRuntimeCapabilities(target)
|
||||
const projectProviderIdentity =
|
||||
args.projectProviderIdentity ??
|
||||
get().projects.find((project) => project.id === args.projectId)?.providerIdentity
|
||||
// Why: the target host may not have a project record yet; carry the selected source-host identity across the boundary.
|
||||
const setupArgs = projectProviderIdentity ? { ...args, projectProviderIdentity } : args
|
||||
const result =
|
||||
target.kind === 'local'
|
||||
? await window.api.projects.setupExistingFolder(args)
|
||||
? await window.api.projects.setupExistingFolder(setupArgs)
|
||||
: (
|
||||
await callRuntimeRpc<{ result: ProjectHostSetupResult }>(
|
||||
target,
|
||||
'projectHostSetup.setupExistingFolder',
|
||||
args,
|
||||
setupArgs,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).result
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getProjectHostSetupsForProject,
|
||||
getProjectHostSetupWorktreeMeta,
|
||||
isGitHubBackedRepo,
|
||||
getProjectIdForProviderIdentity,
|
||||
isProjectRemoteIdentityPending
|
||||
} from './project-host-setup-projection'
|
||||
import type { Repo } from './types'
|
||||
@@ -510,6 +511,22 @@ describe('isGitHubBackedRepo', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProjectIdForProviderIdentity', () => {
|
||||
it('uses the same normalized identity key as project projection', () => {
|
||||
expect(
|
||||
getProjectIdForProviderIdentity({ provider: 'github', owner: 'PyTorch', repo: 'PyTorch' })
|
||||
).toBe('github:pytorch/pytorch')
|
||||
expect(
|
||||
getProjectIdForProviderIdentity({
|
||||
provider: 'github',
|
||||
owner: 'Acme',
|
||||
repo: 'Orca',
|
||||
host: 'GITHUB.ACME.TEST:8443'
|
||||
})
|
||||
).toBe('github:github.acme.test:8443/acme/orca')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isProjectRemoteIdentityPending', () => {
|
||||
const base = { id: 'r', path: '/r', displayName: 'r' } as const
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ export function getProjectIdentityKey(
|
||||
): string {
|
||||
const identity = getProjectProviderIdentity(repo)
|
||||
if (identity) {
|
||||
return `github:${githubRepoIdentityKey(identity)}`
|
||||
return getProjectIdForProviderIdentity(identity)
|
||||
}
|
||||
const gitRemoteIdentity = getProjectGitRemoteIdentity(repo)
|
||||
if (gitRemoteIdentity) {
|
||||
@@ -110,6 +110,10 @@ export function getProjectIdentityKey(
|
||||
return `repo:${repo.id}`
|
||||
}
|
||||
|
||||
export function getProjectIdForProviderIdentity(identity: ProjectProviderIdentity): string {
|
||||
return `github:${githubRepoIdentityKey(identity)}`
|
||||
}
|
||||
|
||||
function getProjectId(
|
||||
repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
|
||||
): string {
|
||||
|
||||
@@ -163,6 +163,7 @@ export type ProjectHostSetup = {
|
||||
|
||||
export type ProjectHostSetupExistingFolderArgs = {
|
||||
projectId: string
|
||||
projectProviderIdentity?: ProjectProviderIdentity
|
||||
hostId: ExecutionHostId
|
||||
path: string
|
||||
kind?: RepoKind
|
||||
@@ -185,6 +186,7 @@ export type ProjectHostSetupCreateArgs = {
|
||||
|
||||
export type ProjectHostSetupCloneArgs = {
|
||||
projectId: string
|
||||
projectProviderIdentity?: ProjectProviderIdentity
|
||||
hostId: ExecutionHostId
|
||||
url: string
|
||||
destination: string
|
||||
|
||||
Reference in New Issue
Block a user