Merge latest origin/main into brennanb2025/native-chat-multiline-send

This commit is contained in:
Brennan Benson
2026-07-29 19:04:15 -07:00
13 changed files with 359 additions and 62 deletions
+71
View File
@@ -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
View File
@@ -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)
}
+139
View File
@@ -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>[] = []
+56 -24
View File
@@ -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'
@@ -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(')
+4 -1
View File
@@ -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'
+7 -2
View File
@@ -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
+5 -1
View File
@@ -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 {
+2
View File
@@ -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