refactor(renderer): no client SSH operation arms in project creation or native chat (#27593)

* refactor(renderer): no client SSH operation arms in project creation or native chat

* fix(add-project): show the start step when the remote step loses its server

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-11 14:56:50 -07:00
committed by GitHub
co-authored by m4air
parent c180ba6338
commit 6dafde48e7
66 changed files with 579 additions and 2335 deletions
@@ -4403,15 +4403,6 @@
"auto.components.sidebar.add.repo.local.start.actions.runtimeBrowseDescription": {
"ko": "이 호스트의 기존 Git 리포지토리 또는 폴더"
},
"auto.components.sidebar.add.repo.local.start.actions.sshBrowseDescription": {
"ko": "이 SSH 호스트의 기존 Git 리포지토리 또는 폴더"
},
"auto.components.sidebar.add.repo.local.start.actions.sshBrowseTitle": {
"ko": "SSH 호스트에서 프로젝트 열기"
},
"auto.components.sidebar.add.repo.local.start.actions.sshCreateUnavailable": {
"ko": "아직 SSH 호스트에서는 사용할 수 없습니다."
},
"auto.components.sidebar.index.b826a98b6f": {
"ko": "사용 중"
},
@@ -304,25 +304,10 @@ it('keeps a local image paste after the real composer closes and reopens', async
await waitFor(() => expect(mocks.state.fieldProps?.imageAttachments?.[0]?.pending).toBeFalsy())
})
it.each([
{ kind: 'local', acceptsImages: false },
{ kind: 'ssh', acceptsImages: false },
{ kind: 'local', acceptsImages: true },
{ kind: 'ssh', acceptsImages: true }
] as const)(
'can remove a stalled $kind paste after reopen with acceptsImages=$acceptsImages',
async ({ kind, acceptsImages }) => {
mocks.state.owner =
kind === 'local'
? { kind: 'local' }
: {
kind: 'ssh',
connectionId: 'ssh-1',
worktreePath: '/remote/folder',
expectedExecutionHostId: 'ssh:ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 1
}
it.each([false, true])(
'can remove a stalled paste after reopen with acceptsImages=%s',
async (acceptsImages) => {
mocks.state.owner = { kind: 'local' }
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
const finishUpload = holdUpload()
writeNativeChatDraftCache(PANE, 'look at this')
@@ -338,13 +323,7 @@ it.each([
fireEvent.click(screen.getByRole('button', { name: 'Remove attachment' }))
)
expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false)
await act(async () =>
finishUpload(
kind === 'local'
? '/local/native-chat-pastes/orca-paste-1-image.png'
: '/tmp/orca-paste-1-image.png'
)
)
await act(async () => finishUpload('/local/native-chat-pastes/orca-paste-1-image.png'))
expect(readNativeChatAttachmentCache(PANE)).toEqual([])
expect(readNativeChatDraftCache(PANE)).toBe('look at this')
expect(mocks.state.fieldProps?.imageAttachments).toEqual([])
@@ -5,25 +5,11 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { getDefaultSettings } from '../../../../shared/constants'
const mocks = vi.hoisted(() => ({
toastLoading: vi.fn(() => 'toast-1'),
toastDismiss: vi.fn(),
toastError: vi.fn(),
toastMessage: vi.fn(),
resolveDroppedPathsForAgent: vi.fn(),
callRuntimeRpc: vi.fn()
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
vi.mock('sonner', () => ({
toast: {
loading: mocks.toastLoading,
dismiss: mocks.toastDismiss,
error: mocks.toastError,
message: mocks.toastMessage
}
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, unknown>) =>
fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => String(values?.[name]))
@@ -34,8 +20,7 @@ import {
prepareNativeChatSessionAttachmentUpload,
resolveNativeChatAttachmentOwner,
resolveNativeChatAttachmentOwnerForWorktree,
resolveNativeChatRuntimeSessionAttachmentOwner,
uploadNativeChatAttachmentPaths
resolveNativeChatRuntimeSessionAttachmentOwner
} from './native-chat-attachment-upload'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
@@ -95,42 +80,15 @@ describe('resolveNativeChatAttachmentOwner', () => {
})
})
it('resolves a structured SSH owner directly from its worktree', () => {
expect(
resolveNativeChatAttachmentOwnerForWorktree(
state({
repos: [{ id: 'repo', connectionId: 'conn-1' }] as never,
sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]])
}),
'wt-1'
)
).toMatchObject({
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/repo/worktree'
it('refuses client files for an SSH-owned worktree, whatever its connection state', () => {
const sshState = state({ repos: [repoFixture({ connectionId: 'conn-1' })] })
expect(resolveNativeChatAttachmentOwner(sshState, 'tab-1')).toEqual({ kind: 'remote' })
expect(resolveNativeChatAttachmentOwnerForWorktree(sshState, 'wt-1')).toEqual({
kind: 'remote'
})
})
it('resolves an SSH repo worktree to ssh with the worktree path', () => {
expect(
resolveNativeChatAttachmentOwner(
state({
repos: [{ id: 'repo', connectionId: 'conn-1' }] as never,
sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]])
}),
'tab-1'
)
).toEqual({
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/repo/worktree',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
})
it('resolves a runtime-owned repo to runtime', () => {
it('refuses client files for a runtime-owned repo', () => {
expect(
resolveNativeChatAttachmentOwner(
state({
@@ -138,7 +96,7 @@ describe('resolveNativeChatAttachmentOwner', () => {
}),
'tab-1'
)
).toEqual({ kind: 'runtime' })
).toEqual({ kind: 'remote' })
})
it('keeps a recorded local workspace local when a runtime is focused', () => {
@@ -161,90 +119,6 @@ describe('resolveNativeChatAttachmentOwner', () => {
kind: 'not-ready'
})
})
it('reports not-ready instead of throwing when the SSH generation is gone', () => {
expect(
resolveNativeChatAttachmentOwner(
state({
repos: [{ id: 'repo', connectionId: 'conn-1' }] as never,
sshConnectionStates: new Map()
}),
'tab-1'
)
).toEqual({ kind: 'not-ready' })
})
it('reports not-ready when an SSH worktree has no known path yet', () => {
expect(
resolveNativeChatAttachmentOwner(
state({
repos: [{ id: 'repo', connectionId: 'conn-1' }] as never,
worktreesByRepo: { repo: [{ id: 'wt-1', repoId: 'repo' } as never] },
tabsByWorktree: { 'wt-1': [terminalTab()] }
}),
'tab-1'
)
).toEqual({ kind: 'not-ready' })
})
})
describe('uploadNativeChatAttachmentPaths', () => {
const owner = {
kind: 'ssh' as const,
connectionId: 'conn-1',
worktreePath: '/remote/worktree',
expectedExecutionHostId: 'ssh:conn-1' as const,
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('window', {
api: { fs: { resolveDroppedPathsForAgent: mocks.resolveDroppedPathsForAgent } }
})
})
it('uploads through the terminal drop resolver and returns remote paths', async () => {
mocks.resolveDroppedPathsForAgent.mockResolvedValue({
resolvedPaths: ['/remote/worktree/.orca/drops/a.txt'],
skipped: [],
failed: []
})
await expect(uploadNativeChatAttachmentPaths(['/local/a.txt'], owner)).resolves.toEqual([
'/remote/worktree/.orca/drops/a.txt'
])
expect(mocks.resolveDroppedPathsForAgent).toHaveBeenCalledWith({
paths: ['/local/a.txt'],
worktreePath: '/remote/worktree',
connectionId: 'conn-1',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
expect(mocks.toastLoading).toHaveBeenCalledTimes(1)
expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1')
})
it('surfaces per-file skips and failures through the shared drop toasts', async () => {
mocks.resolveDroppedPathsForAgent.mockResolvedValue({
resolvedPaths: [],
skipped: [{ sourcePath: '/local/link', reason: 'symlink' }],
failed: [{ sourcePath: '/local/b.txt', reason: 'boom' }]
})
await expect(
uploadNativeChatAttachmentPaths(['/local/link', '/local/b.txt'], owner)
).resolves.toEqual([])
expect(mocks.toastMessage).toHaveBeenCalledTimes(1)
expect(mocks.toastError).toHaveBeenCalledTimes(1)
})
it('returns null and reports when the upload IPC fails', async () => {
mocks.resolveDroppedPathsForAgent.mockRejectedValue(new Error('sftp down'))
await expect(uploadNativeChatAttachmentPaths(['/local/a.txt'], owner)).resolves.toBeNull()
expect(mocks.toastError).toHaveBeenCalledTimes(1)
expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1')
})
})
describe('a structured chat on a paired server', () => {
@@ -1,16 +1,12 @@
// Host-aware resolution for composer attachments (STA-1465). The composer's
// attach surfaces (file drop, file picker, image paste) receive client-local
// paths, but an SSH worktree's agent runs on the remote host — local paths must
// be uploaded first, exactly like terminal drops (docs/terminal-drop-ssh.md). A
// structured chat on a paired server uploads into that server's attachment store.
// paths. A structured chat on a paired server uploads into that server's
// attachment store; any other remote chat refuses client files.
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta'
import {
getConnectionExecutionHostId,
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
@@ -30,19 +26,8 @@ import type {
AgentSessionAttachmentUploadTarget
} from '../../../../shared/agent-session-attachments'
import type { AppState } from '@/store/types'
import { reportTerminalDropUploadSkipsAndFailures } from '../terminal-pane/terminal-drop-upload-report'
import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop'
import { findTerminalTabWorktreeId } from './native-chat-file-link'
import {
captureDirectSshMutationExpectation,
type DirectSshMutationExpectation
} from '@/lib/ssh-mutation-expectation'
export type NativeChatSshAttachmentOwner = DirectSshMutationExpectation & {
kind: 'ssh'
connectionId: string
worktreePath: string
}
/** A structured chat on a paired server: files upload into that server's attachment store, which
* checks every stored path a message names when it admits the message. */
@@ -55,11 +40,11 @@ export type NativeChatRuntimeSessionAttachmentOwner = {
export type NativeChatAttachmentOwner =
| { kind: 'local' }
| NativeChatSshAttachmentOwner
| NativeChatRuntimeSessionAttachmentOwner
/** A terminal-backed chat on a paired server: its agent may run on that server's SSH or WSL
* host, which the server's store cannot reach, so it keeps refusing client files. */
| { kind: 'runtime' }
/** Refuses client files: a terminal-backed chat on a paired server (its agent may run on that
* server's SSH or WSL host, which the server's store cannot reach), or an `ssh:` host, which
* only its paired server serves. */
| { kind: 'remote' }
/** Store not hydrated / worktree unknown. Callers must not attach local
* paths in this window — the worktree may turn out to be remote, and the
* agent would silently receive paths it cannot read (see #6648). */
@@ -118,35 +103,10 @@ export function resolveNativeChatAttachmentOwnerForWorktree(
if (!hostId) {
return { kind: 'not-ready' }
}
if (parseExecutionHostId(hostId)?.kind === 'runtime') {
return { kind: 'runtime' }
}
if (hostId === 'local') {
return { kind: 'local' }
}
const connectionId = getConnectionIdFromState(state, worktreeId)
if (connectionId === undefined) {
return { kind: 'not-ready' }
}
if (connectionId === null) {
return { kind: 'not-ready' }
}
const worktreePath = findKnownWorktreeById(state, worktreeId, hostId)?.path
if (!worktreePath) {
return { kind: 'not-ready' }
}
try {
return {
kind: 'ssh',
connectionId,
worktreePath,
...captureDirectSshMutationExpectation(state, connectionId)
}
} catch {
// The connection's generation is gone (disconnect mid-attach). That is an
// unknown owner, not a reason to throw out of the drop/IME handler.
return { kind: 'not-ready' }
}
return { kind: 'remote' }
}
/** The chat behind a structured composer, as the attachment owner needs it. */
@@ -220,42 +180,6 @@ export function nativeChatTooManyAttachmentsNotice(): string {
export { nativeChatLocalAttachmentUnsupportedNotice } from './native-chat-composer-target'
/**
* Upload client-local paths into `${worktreePath}/.orca/drops` on the SSH
* remote and return the remote paths the agent can read (input order
* preserved). Returns null when the upload IPC itself failed; per-file
* skips/failures surface through the shared drop toasts.
*/
export async function uploadNativeChatAttachmentPaths(
paths: string[],
owner: NativeChatSshAttachmentOwner
): Promise<string[] | null> {
const pending = toast.loading(
translate(
'components.native-chat.composer.uploadingAttachments',
'Uploading {{value0}} file(s) to remote…',
{ value0: paths.length }
)
)
try {
const { resolvedPaths, skipped, failed } = await window.api.fs.resolveDroppedPathsForAgent({
paths,
worktreePath: owner.worktreePath,
connectionId: owner.connectionId,
expectedExecutionHostId: owner.expectedExecutionHostId,
expectedSshTargetId: owner.expectedSshTargetId,
expectedSshConnectionGeneration: owner.expectedSshConnectionGeneration
})
reportTerminalDropUploadSkipsAndFailures(skipped, failed)
return resolvedPaths
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to upload files.'))
return null
} finally {
toast.dismiss(pending)
}
}
/**
* Ask the chat's server, right before uploading, whether it keeps chat attachments, and which
* server process will receive the bytes. An older server gets the update notice instead of an
@@ -6,20 +6,17 @@ import {
type NativeChatAttachmentOwner
} from './native-chat-attachment-upload'
type ClipboardImageOwner = Extract<
NativeChatAttachmentOwner,
{ kind: 'local' | 'ssh' | 'runtime-session' }
>
type ClipboardImageOwner = Extract<NativeChatAttachmentOwner, { kind: 'local' | 'runtime-session' }>
/** Owners whose attachment path is a file this client can write right now. */
export function ownerAcceptsClipboardImage(
owner: NativeChatAttachmentOwner
): owner is ClipboardImageOwner {
return owner.kind === 'local' || owner.kind === 'ssh' || owner.kind === 'runtime-session'
return owner.kind === 'local' || owner.kind === 'runtime-session'
}
/** Where the clipboard image is saved: this machine, the SSH host, or the chat's store on its
* paired server, after asking that server whether it keeps one. */
/** Where the clipboard image is saved: this machine, or the chat's store on its paired server,
* after asking that server whether it keeps one. */
async function clipboardImageSaveArgs(
owner: ClipboardImageOwner
): Promise<
@@ -29,9 +26,6 @@ async function clipboardImageSaveArgs(
if (owner.kind === 'local') {
return { ok: true, args: { forNativeChatDraft: true } }
}
if (owner.kind === 'ssh') {
return { ok: true, args: { connectionId: owner.connectionId } }
}
const prepared = await prepareNativeChatSessionAttachmentUpload(owner)
if (!prepared.ok) {
return { ...prepared, cause: 'serverTooOld' }
@@ -25,20 +25,21 @@ const electron = vi.hoisted(() => ({
getPathForFile: vi.fn((file: File) => `/repro/${file.name}`)
}))
const intake = vi.hoisted(() => ({
owner: { kind: 'local' } as { kind: string; connectionId?: string },
stat: vi.fn(),
readFile: vi.fn(),
upload: vi.fn(),
prepare: vi.fn(async ({ paths }: { paths: string[] }) => ({ paths, failures: [] })),
pick: vi.fn()
}))
const intake = vi.hoisted(() => {
const owner: { kind: string } = { kind: 'local' }
return {
owner,
stat: vi.fn(),
readFile: vi.fn(),
prepare: vi.fn(async ({ paths }: { paths: string[] }) => ({ paths, failures: [] })),
pick: vi.fn()
}
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ tabsByWorktree: {} }) } }))
// Keeps the real notice strings so the silent-failure guards assert what users see.
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
...(await importOriginal<typeof AttachmentUploadModule>()),
resolveNativeChatAttachmentOwner: () => intake.owner,
uploadNativeChatAttachmentPaths: intake.upload
resolveNativeChatAttachmentOwner: () => intake.owner
}))
vi.mock('electron', () => ({
@@ -182,7 +183,6 @@ describe('native chat composer drop scoping', () => {
electron.getPathForFile.mockReset().mockImplementation((file: File) => `/repro/${file.name}`)
intake.stat.mockReset().mockResolvedValue(undefined)
intake.readFile.mockReset().mockResolvedValue({ content: '', isBinary: false })
intake.upload.mockReset()
intake.prepare.mockReset().mockImplementation(async ({ paths }) => ({ paths, failures: [] }))
intake.pick.mockReset()
vi.stubGlobal('IntersectionObserver', undefined)
@@ -232,10 +232,9 @@ describe('native chat composer drop scoping', () => {
intake.prepare.mockImplementationOnce(() => gate.promise)
const view = render(<ComposerProbe pane="chat-a" />)
await dropTwoImages(view.container.querySelector('.ProseMirror')!)
intake.owner = { kind: 'ssh', connectionId: 'other-host' }
intake.owner = { kind: 'remote' }
await act(async () => gate.resolve({ paths: ['/prepared/a.png'], failures: [] }))
expect(intake.stat).not.toHaveBeenCalled()
expect(intake.upload).not.toHaveBeenCalled()
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
})
it('refuses preparation captured for a draft that changed in the same pane', async () => {
@@ -281,7 +280,7 @@ describe('native chat composer drop scoping', () => {
it('notices an OS drop whose owner changes while checking the files', async () => {
intake.stat.mockImplementation(async () => {
intake.owner = { kind: 'ssh', connectionId: 'conn-1' }
intake.owner = { kind: 'remote' }
})
const view = render(<ComposerProbe pane="chat-a" />)
@@ -415,33 +414,10 @@ describe('native chat composer drop scoping', () => {
[{ filePath: '/repro/second.png', access: { kind: 'user-file' } }]
])
expect(intake.readFile).toHaveBeenCalledTimes(2)
expect(intake.upload).not.toHaveBeenCalled()
expect(readNativeChatAttachmentCache('chat-a')).toHaveLength(2)
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
await expect(intake.readFile({ filePath: '/repro/sibling.png' })).rejects.toThrow(
'Access denied'
)
})
it('uploads once for the SSH drop owner without checking remote paths locally', async () => {
intake.owner = { kind: 'ssh', connectionId: 'conn-1' }
intake.upload.mockResolvedValue(['/remote/first.png', '/remote/second.png'])
const view = render(
<>
<ComposerProbe pane="chat-a" />
<ComposerProbe pane="chat-b" hidden />
</>
)
await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!)
expect(intake.upload).toHaveBeenCalledExactlyOnceWith(
['/repro/first.png', '/repro/second.png'],
intake.owner
)
expect(intake.stat).not.toHaveBeenCalled()
expect(readNativeChatAttachmentCache('chat-a').map(({ path }) => path)).toEqual([
'/remote/first.png',
'/remote/second.png'
])
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
})
})
@@ -18,18 +18,12 @@ import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-even
const testState: {
executionHostId: ExecutionHostId
ownerConnectionId: string
ownerKind: 'local' | 'not-ready' | 'runtime' | 'ssh'
ownerSshGeneration: number
ownerWorktreePath: string
ownerKind: 'local' | 'not-ready' | 'remote'
targetIsRemoteRuntime: boolean
store: { tabsByWorktree: Record<string, { id: string }[]> }
} = vi.hoisted(() => ({
executionHostId: 'local',
ownerConnectionId: 'ssh-1',
ownerKind: 'local',
ownerSshGeneration: 4,
ownerWorktreePath: '/remote/repo',
targetIsRemoteRuntime: false,
store: {
tabsByWorktree: {
@@ -52,17 +46,7 @@ vi.mock('@/lib/worktree-runtime-owner', () => ({
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
...(await importOriginal<typeof AttachmentUploadModule>()),
resolveNativeChatAttachmentHost: () => testState.executionHostId,
resolveNativeChatAttachmentOwnerForWorktree: () =>
testState.ownerKind === 'ssh'
? {
kind: 'ssh',
connectionId: testState.ownerConnectionId,
worktreePath: testState.ownerWorktreePath,
expectedExecutionHostId: `ssh:${testState.ownerConnectionId}`,
expectedSshTargetId: testState.ownerConnectionId,
expectedSshConnectionGeneration: testState.ownerSshGeneration
}
: { kind: testState.ownerKind }
resolveNativeChatAttachmentOwnerForWorktree: () => ({ kind: testState.ownerKind })
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
@@ -19,18 +19,12 @@ import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-even
const testState: {
executionHostId: ExecutionHostId
ownerConnectionId: string
ownerKind: 'local' | 'not-ready' | 'runtime' | 'ssh'
ownerSshGeneration: number
ownerWorktreePath: string
ownerKind: 'local' | 'not-ready' | 'remote'
targetIsRemoteRuntime: boolean
store: { tabsByWorktree: Record<string, { id: string }[]> }
} = vi.hoisted(() => ({
executionHostId: 'local',
ownerConnectionId: 'ssh-1',
ownerKind: 'local',
ownerSshGeneration: 4,
ownerWorktreePath: '/remote/repo',
targetIsRemoteRuntime: false,
store: {
tabsByWorktree: {
@@ -53,17 +47,7 @@ vi.mock('@/lib/worktree-runtime-owner', () => ({
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
...(await importOriginal<typeof AttachmentUploadModule>()),
resolveNativeChatAttachmentHost: () => testState.executionHostId,
resolveNativeChatAttachmentOwnerForWorktree: () =>
testState.ownerKind === 'ssh'
? {
kind: 'ssh',
connectionId: testState.ownerConnectionId,
worktreePath: testState.ownerWorktreePath,
expectedExecutionHostId: `ssh:${testState.ownerConnectionId}`,
expectedSshTargetId: testState.ownerConnectionId,
expectedSshConnectionGeneration: testState.ownerSshGeneration
}
: { kind: testState.ownerKind }
resolveNativeChatAttachmentOwnerForWorktree: () => ({ kind: testState.ownerKind })
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
@@ -250,10 +234,7 @@ function dispatchDragEvent(
describe('native chat workspace file drops', () => {
beforeEach(() => {
testState.executionHostId = 'local'
testState.ownerConnectionId = 'ssh-1'
testState.ownerKind = 'local'
testState.ownerSshGeneration = 4
testState.ownerWorktreePath = '/remote/repo'
testState.targetIsRemoteRuntime = false
testState.store.tabsByWorktree = { 'worktree-1': [{ id: 'terminal-tab-1' }] }
latestInput = null
@@ -356,7 +337,7 @@ describe('native chat workspace file drops', () => {
// explorer lists that host's filesystem, so the agent can read what it drags.
it('accepts a same-host drop on a remote runtime target and refuses a foreign one', () => {
testState.executionHostId = 'runtime:env-1'
testState.ownerKind = 'runtime'
testState.ownerKind = 'remote'
testState.targetIsRemoteRuntime = true
const view = render(<ComposerProbe structured={false} />)
dispatchDragEvent(
@@ -404,44 +385,6 @@ describe('native chat workspace file drops', () => {
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
})
it('rejects an IME-queued path when its SSH route changes under the same host', () => {
testState.executionHostId = 'runtime:outer-env'
testState.ownerKind = 'ssh'
render(<ComposerProbe initialDraft="preedit" />)
const input = editor()
fireEvent.compositionStart(input)
dispatchDragEvent(
'drop',
input,
internalTransfer(['/remote/repo/a.ts'], { executionHostId: 'runtime:outer-env' })
)
testState.ownerConnectionId = 'ssh-2'
fireEvent.compositionEnd(input, { data: '' })
expect(screen.getByTestId('draft').textContent).toBe('preedit')
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
})
it('rejects an IME-queued path when the SSH connection reconnects under the same id', () => {
testState.executionHostId = 'ssh:ssh-1'
testState.ownerKind = 'ssh'
render(<ComposerProbe initialDraft="preedit" />)
const input = editor()
fireEvent.compositionStart(input)
dispatchDragEvent(
'drop',
input,
internalTransfer(['/remote/repo/a.ts'], { executionHostId: 'ssh:ssh-1' })
)
testState.ownerSshGeneration = 5
fireEvent.compositionEnd(input, { data: '' })
expect(screen.getByTestId('draft').textContent).toBe('preedit')
expect(screen.getByText('Files can only be attached to their source workspace.')).toBeTruthy()
})
// The queued check must ask which workspace this composer serves NOW. Comparing
// a captured id against itself would pass no matter where the pane ended up.
it('rejects an IME-queued path when the pane changes workspace before settling', () => {
@@ -470,7 +413,7 @@ describe('native chat workspace file drops', () => {
expect(screen.getByTestId('draft').textContent).toBe('')
testState.executionHostId = 'runtime:my-unresolved-owner-env'
testState.ownerKind = 'runtime'
testState.ownerKind = 'remote'
view.rerender(<ComposerProbe />)
dispatchDragEvent(
'drop',
@@ -482,9 +425,9 @@ describe('native chat workspace file drops', () => {
expect(screen.getByTestId('draft').textContent).toBe('@/repo/accepted.ts ')
})
it('attaches same-owner SSH images without uploading or client authorization', () => {
it('keeps an SSH-host image tagged with its host, never read as a client file', () => {
testState.executionHostId = 'ssh:ssh-1'
testState.ownerKind = 'ssh'
testState.ownerKind = 'remote'
render(<ComposerProbe />)
dispatchDragEvent(
'drop',
@@ -15,13 +15,11 @@ const mocks = vi.hoisted(() => {
owner: NativeChatAttachmentOwner
remoteTarget: boolean
stat: ReturnType<typeof vi.fn>
upload: ReturnType<typeof vi.fn>
tabsByWorktree: Record<string, { id: string }[]>
} = {
owner: { kind: 'local' },
remoteTarget: false,
stat: vi.fn(),
upload: vi.fn(),
tabsByWorktree: { 'workspace-1': [{ id: 'tab-1' }] }
}
return state
@@ -36,8 +34,7 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({
vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
...(await importOriginal<typeof AttachmentUploadModule>()),
resolveNativeChatAttachmentOwner: () => mocks.owner,
resolveNativeChatAttachmentOwnerForWorktree: () => mocks.owner,
uploadNativeChatAttachmentPaths: mocks.upload
resolveNativeChatAttachmentOwnerForWorktree: () => mocks.owner
}))
type Api = ReturnType<typeof useNativeChatExternalAttachments> &
@@ -98,7 +95,6 @@ beforeEach(() => {
mocks.owner = { kind: 'local' }
mocks.remoteTarget = false
mocks.stat.mockReset().mockResolvedValue(undefined)
mocks.upload.mockReset().mockResolvedValue(['/remote/file.txt'])
mocks.tabsByWorktree = { 'workspace-1': [{ id: 'tab-1' }] }
vi.stubGlobal('api', { fs: { stat: mocks.stat } })
container = document.createElement('div')
@@ -114,19 +110,9 @@ afterEach(() => {
})
describe('OS attachment destination through input-method composition', () => {
it.each(['workspace', 'host', 'ssh-generation', 'terminal-workspace'])(
it.each(['workspace', 'host', 'terminal-workspace'])(
'refuses queued files after the %s changes',
async (change) => {
if (change === 'ssh-generation') {
mocks.owner = {
kind: 'ssh',
connectionId: 'ssh-1',
worktreePath: '/remote',
expectedExecutionHostId: 'ssh:ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 1
}
}
await render(change === 'terminal-workspace' ? undefined : 'workspace-1')
await act(async () => latest().attachExternalPaths(['/local/file.txt', '/local/image.png']))
expect(container.querySelector('[data-draft]')?.textContent).toBe('')
@@ -134,10 +120,7 @@ describe('OS attachment destination through input-method composition', () => {
await render('workspace-2')
}
if (change === 'host') {
mocks.owner = { kind: 'runtime' }
}
if (change === 'ssh-generation' && mocks.owner.kind === 'ssh') {
mocks.owner = { ...mocks.owner, expectedSshConnectionGeneration: 2 }
mocks.owner = { kind: 'remote' }
}
if (change === 'terminal-workspace') {
mocks.tabsByWorktree = { 'workspace-1': [] }
@@ -160,37 +143,6 @@ describe('OS attachment destination through input-method composition', () => {
expect(container.querySelector('[data-draft]')?.textContent).toBe('@/local/file.txt ')
})
it('refuses an obsolete SSH drop and attaches the retry once during the same composition', async () => {
mocks.owner = {
kind: 'ssh',
connectionId: 'ssh-1',
worktreePath: '/remote',
expectedExecutionHostId: 'ssh:ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 1
}
mocks.upload
.mockResolvedValueOnce(['/remote/a.txt', '/remote/a.png'])
.mockResolvedValueOnce(['/remote/b.txt', '/remote/b.png'])
await render('workspace-1')
await act(async () => latest().attachExternalPaths(['/local/a.txt', '/local/a.png']))
mocks.owner = { ...mocks.owner, expectedSshConnectionGeneration: 2 }
await act(async () => latest().attachExternalPaths(['/local/b.txt', '/local/b.png']))
expect(container.querySelector('[data-draft]')?.textContent).toBe('')
composing = false
act(() => {
latest().flushPendingAttachments()
latest().flushPendingAttachments()
})
expect(container.querySelector('[data-draft]')?.textContent).toBe('@/remote/b.txt ')
expect(latest().imageAttachments).toEqual([
expect.objectContaining({ path: '/remote/b.png', connectionId: 'ssh-1' })
])
expect(container.querySelector('[data-notice]')?.textContent).toBe(
'This workspace changed hosts while attaching — drop the files again.'
)
})
it.each([true, false])(
'keeps the workspace-file exemption off OS drops (queued=$queued)',
async (queued) => {
@@ -95,6 +95,18 @@ describe('resolveNativeChatImageRuntimeContext', () => {
})
})
it('reads nothing for an SSH-owned workspace with no paired server to serve it', () => {
const worktree = worktreeFixture('wt-1', '/remote/worktree', { hostId: 'ssh:ssh-1' })
const sshState = state({
activeWorkspaceExecutionHostId: 'ssh:ssh-1',
getKnownWorktreeById: () => worktree,
repos: [repoFixture({ connectionId: 'ssh-1' })],
worktreesByRepo: { repo: [worktree] }
})
expect(resolveNativeChatImageRuntimeContext(sshState, 'tab-1')).toBeNull()
})
it('resolves a floating chat to its pinned folder on the local host, and nothing before the pin', () => {
const floatingTab: Tab = {
id: 'floating-chat-1',
@@ -121,7 +121,8 @@ export function resolveNativeChatImageRuntimeContext(
return null
}
const host = parseExecutionHostId(executionHostId)
if (!host) {
// Why: an `ssh:` host is served only through its paired server; without one there is no route.
if (!host || (host.kind === 'ssh' && !route.runtimeEnvironmentId)) {
return null
}
const context: RuntimeFileOperationArgs = {
@@ -139,10 +140,6 @@ export function resolveNativeChatImageRuntimeContext(
)
context.expectedSshTargetId = expectation.expectedSshTargetId
context.expectedSshConnectionGeneration = expectation.expectedSshConnectionGeneration
if (!route.runtimeEnvironmentId) {
context.connectionId = host.targetId
context.expectedExternalSshTargetId = host.targetId
}
} catch {
return null
}
@@ -3,24 +3,11 @@ import { describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership'
import type { NativeChatSshAttachmentOwner } from './native-chat-attachment-upload'
function ssh(overrides: Partial<NativeChatSshAttachmentOwner> = {}): NativeChatSshAttachmentOwner {
return {
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4,
...overrides
}
}
describe('nativeChatAttachmentOwnerUnchanged', () => {
it('keeps same-kind local and runtime owners', () => {
it('keeps same-kind local and remote owners', () => {
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'local' }, { kind: 'local' })).toBe(true)
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'runtime' }, { kind: 'runtime' })).toBe(true)
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'remote' }, { kind: 'remote' })).toBe(true)
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'local' }, { kind: 'remote' })).toBe(false)
})
it('never treats an unknown owner as the same owner', () => {
@@ -29,13 +16,4 @@ describe('nativeChatAttachmentOwnerUnchanged', () => {
)
expect(nativeChatAttachmentOwnerUnchanged({ kind: 'local' }, { kind: 'not-ready' })).toBe(false)
})
it('rejects an SSH reconnect that keeps the same connection id', () => {
expect(nativeChatAttachmentOwnerUnchanged(ssh(), ssh())).toBe(true)
expect(
nativeChatAttachmentOwnerUnchanged(ssh(), ssh({ expectedSshConnectionGeneration: 5 }))
).toBe(false)
expect(nativeChatAttachmentOwnerUnchanged(ssh(), ssh({ connectionId: 'conn-2' }))).toBe(false)
expect(nativeChatAttachmentOwnerUnchanged(ssh(), ssh({ worktreePath: '/other' }))).toBe(false)
})
})
@@ -21,17 +21,5 @@ export function nativeChatAttachmentOwnerUnchanged(
captured: NativeChatAttachmentOwner,
current: NativeChatAttachmentOwner
): boolean {
if (captured.kind !== current.kind || captured.kind === 'not-ready') {
return false
}
if (captured.kind !== 'ssh' || current.kind !== 'ssh') {
return true
}
return (
captured.connectionId === current.connectionId &&
captured.worktreePath === current.worktreePath &&
captured.expectedExecutionHostId === current.expectedExecutionHostId &&
captured.expectedSshTargetId === current.expectedSshTargetId &&
captured.expectedSshConnectionGeneration === current.expectedSshConnectionGeneration
)
return captured.kind === current.kind && captured.kind !== 'not-ready'
}
@@ -189,15 +189,6 @@ beforeEach(() => {
mocks.saveClipboardImageAsTempFile.mockResolvedValue(null)
})
const sshOwner: NativeChatAttachmentOwner = {
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
}
afterEach(() => {
root?.unmount()
root = null
@@ -210,7 +201,7 @@ describe('useNativeChatComposerPaste', () => {
const setNotice = vi.fn()
const store = createChipStore()
const probe = await renderProbe({
resolveAttachmentOwner: () => ({ kind: 'runtime' }),
resolveAttachmentOwner: () => ({ kind: 'remote' }),
store,
setNotice
})
@@ -225,23 +216,19 @@ describe('useNativeChatComposerPaste', () => {
expect(store.chips).toHaveLength(0)
})
it('surfaces a failed SSH image save through the composer notice', async () => {
mocks.saveClipboardImageAsTempFile.mockRejectedValue(
new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.')
)
it('surfaces a failed image save through the composer notice', async () => {
mocks.saveClipboardImageAsTempFile.mockRejectedValue(new Error('Disk full.'))
const store = createChipStore()
const setNotice = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => sshOwner,
resolveAttachmentOwner: () => ({ kind: 'local' }),
store,
setNotice
})
await act(async () => {
probe.latest().handlePaste(imagePasteEvent())
})
expect(setNotice).toHaveBeenCalledWith(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
expect(setNotice).toHaveBeenCalledWith('Disk full.')
// The optimistic chip must not outlive a failed save.
expect(store.chips).toHaveLength(0)
})
@@ -249,14 +236,14 @@ describe('useNativeChatComposerPaste', () => {
it.each(['event', 'menu'] as const)(
'attaches OMP clipboard images for reference delivery via %s paste',
async (source) => {
mocks.saveClipboardImageAsTempFile.mockResolvedValue('/remote/tmp/omp.png')
mocks.saveClipboardImageAsTempFile.mockResolvedValue('/tmp/omp.png')
mocks.readClipboardImageThumbnail.mockResolvedValue(null)
const store = createChipStore()
const attachResolvedPaths = vi.fn()
const setNotice = vi.fn()
const probe = await renderProbe({
agent: 'omp',
resolveAttachmentOwner: () => sshOwner,
resolveAttachmentOwner: () => ({ kind: 'local' }),
store,
attachResolvedPaths,
setNotice
@@ -268,8 +255,8 @@ describe('useNativeChatComposerPaste', () => {
probe.latest().pasteFromClipboard()
}
})
expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledWith({ connectionId: 'conn-1' })
expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/tmp/omp.png'], 'conn-1')
expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledWith({ forNativeChatDraft: true })
expect(attachResolvedPaths).toHaveBeenCalledWith(['/tmp/omp.png'], null)
expect(setNotice.mock.calls.every(([notice]) => notice === null)).toBe(true)
}
)
@@ -285,32 +272,6 @@ describe('useNativeChatComposerPaste', () => {
expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledWith({ forNativeChatDraft: true })
})
it('saves on the SSH host and settles the chip on the returned remote path', async () => {
mocks.saveClipboardImageAsTempFile.mockResolvedValue('/remote/tmp/orca-paste-1.png')
const store = createChipStore()
const attachResolvedPaths = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => sshOwner,
store,
attachResolvedPaths
})
await act(async () => {
probe.latest().handlePaste(imagePasteEvent())
})
expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledWith({ connectionId: 'conn-1' })
expect(store.chips).toEqual([
{
id: 'chip-1',
path: '/remote/tmp/orca-paste-1.png',
previewUrl: 'blob:clipboard-image',
pending: false
}
])
// The chip carries the SSH connection so its preview reads over SFTP.
expect(store.connectionIds).toEqual(['conn-1'])
expect(attachResolvedPaths).not.toHaveBeenCalled()
})
it('shows a pending chip before the save resolves', async () => {
let resolveSave: (path: string) => void = () => {}
mocks.saveClipboardImageAsTempFile.mockReturnValue(
@@ -356,7 +317,7 @@ describe('useNativeChatComposerPaste', () => {
})
expect(store.chips).toHaveLength(1)
owner = sshOwner
owner = { kind: 'remote' }
await act(async () => {
resolveSave('/tmp/orca-paste-owner-changed.png')
})
@@ -418,7 +379,7 @@ describe('useNativeChatComposerPaste', () => {
const insertTypedText = vi.fn()
const setNotice = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => sshOwner,
resolveAttachmentOwner: () => ({ kind: 'local' }),
insertTypedText,
setNotice
})
@@ -474,7 +435,7 @@ describe('useNativeChatComposerPaste', () => {
)
const setNotice = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => sshOwner,
resolveAttachmentOwner: () => ({ kind: 'local' }),
setNotice
})
await act(async () => {
@@ -489,7 +450,7 @@ describe('useNativeChatComposerPaste', () => {
})
describe('composer paste intake regressions', () => {
it.each(['local', 'ssh', 'runtime', 'not-ready'] as const)(
it.each(['local', 'remote', 'not-ready'] as const)(
'inserts text without waiting for an image operation on %s',
async (kind) => {
mocks.readClipboardText.mockResolvedValue('안녕하세요\nhello')
@@ -498,14 +459,14 @@ describe('composer paste intake regressions', () => {
const insertTypedText = vi.fn(() => true)
const setNotice = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => (kind === 'ssh' ? sshOwner : { kind }),
resolveAttachmentOwner: () => ({ kind }),
insertTypedText,
setNotice
})
await act(async () => probe.latest().pasteFromClipboard())
expect(insertTypedText).toHaveBeenCalledExactlyOnceWith('안녕하세요\nhello')
expect(setNotice.mock.calls.filter(([notice]) => notice !== null)).toHaveLength(0)
if (kind === 'runtime' || kind === 'not-ready') {
if (kind === 'remote' || kind === 'not-ready') {
expect(mocks.saveClipboardImageAsTempFile).not.toHaveBeenCalled()
}
}
@@ -523,7 +484,7 @@ describe('composer paste intake regressions', () => {
const insertTypedText = vi.fn(() => true)
const setNotice = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => ({ kind: 'runtime' }),
resolveAttachmentOwner: () => ({ kind: 'remote' }),
insertTypedText,
setNotice
})
@@ -535,7 +496,7 @@ describe('composer paste intake regressions', () => {
}
)
it.each(['runtime', 'not-ready'] as const)(
it.each(['remote', 'not-ready'] as const)(
'still explains an image-only menu paste on %s',
async (kind) => {
mocks.clipboardHasImage.mockResolvedValue(true)
@@ -547,13 +508,13 @@ describe('composer paste intake regressions', () => {
}
)
it.each(['local', 'ssh', 'runtime', 'not-ready'] as const)(
it.each(['local', 'remote', 'not-ready'] as const)(
'preserves mixed event text with %s attachments and deduplicates capture',
async (kind) => {
const insertTypedText = vi.fn(() => true)
const setNotice = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => (kind === 'ssh' ? sshOwner : { kind }),
resolveAttachmentOwner: () => ({ kind }),
insertTypedText,
setNotice
})
@@ -616,8 +577,8 @@ describe('composer paste intake regressions', () => {
}
)
it('rejects an image saved across SSH connection replacement', async () => {
let owner = sshOwner
it('rejects an image saved across a host change', async () => {
let owner: NativeChatAttachmentOwner = { kind: 'local' }
let finish = (_path: string): void => {}
mocks.saveClipboardImageAsTempFile.mockReturnValue(
new Promise<string>((resolve) => {
@@ -627,7 +588,7 @@ describe('composer paste intake regressions', () => {
const store = createChipStore()
const probe = await renderProbe({ resolveAttachmentOwner: () => owner, store })
await act(async () => probe.latest().handlePaste(imagePasteEvent()))
owner = { ...sshOwner, expectedSshConnectionGeneration: 5 }
owner = { kind: 'remote' }
await act(async () => finish('/tmp/old-host.png'))
expect(store.chips).toHaveLength(0)
})
@@ -636,7 +597,7 @@ describe('composer paste intake regressions', () => {
it('inserts event text locally without any clipboard API access', async () => {
const insertTypedText = vi.fn(() => true)
const probe = await renderProbe({
resolveAttachmentOwner: () => ({ kind: 'runtime' }),
resolveAttachmentOwner: () => ({ kind: 'remote' }),
insertTypedText
})
const data = new DataTransfer()
@@ -654,7 +615,7 @@ it('refuses oversized event text without inserting it', async () => {
const insertTypedText = vi.fn(() => true)
const setNotice = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => ({ kind: 'runtime' }),
resolveAttachmentOwner: () => ({ kind: 'remote' }),
insertTypedText,
setNotice
})
@@ -810,7 +771,7 @@ describe('file-manager copies', () => {
mocks.readClipboardFilePaths.mockResolvedValue(['/Users/me/notes.txt'])
const insertTypedText = vi.fn(() => true)
const probe = await renderProbe({
resolveAttachmentOwner: () => sshOwner,
resolveAttachmentOwner: () => ({ kind: 'local' }),
insertTypedText
})
await act(async () => probe.latest().pasteFromClipboard())
@@ -847,7 +808,7 @@ describe('file-manager copies', () => {
const insertTypedText = vi.fn(() => true)
const setNotice = vi.fn()
const probe = await renderProbe({
resolveAttachmentOwner: () => ({ kind: 'runtime' }),
resolveAttachmentOwner: () => ({ kind: 'remote' }),
insertTypedText,
setNotice
})
@@ -108,12 +108,11 @@ export function useNativeChatComposerPaste({
setNotice(nativeChatWorktreeNotReadyNotice())
return
}
const connectionId = originalOwner.kind === 'ssh' ? originalOwner.connectionId : null
if (pendingId) {
lifetime.pending.delete(pendingId)
resolvePendingImageAttachment(pendingId, path, connectionId)
resolvePendingImageAttachment(pendingId, path, null)
} else {
attachResolvedPaths([path], connectionId)
attachResolvedPaths([path], null)
}
},
[
@@ -249,7 +248,7 @@ export function useNativeChatComposerPaste({
insertText(read.text)
} else if (canPaste()) {
setNotice(
owner.kind === 'runtime'
owner.kind === 'remote'
? nativeChatLocalAttachmentUnsupportedNotice()
: nativeChatWorktreeNotReadyNotice()
)
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
stat: vi.fn(),
resolveNativeChatAttachmentOwner: vi.fn(),
resolveNativeChatAttachmentOwnerForWorktree: vi.fn(),
uploadNativeChatAttachmentPaths: vi.fn(),
prepareNativeChatSessionAttachmentUpload: vi.fn(),
uploadNativeChatSessionAttachmentPaths: vi.fn(),
toastLoading: vi.fn()
@@ -29,7 +28,6 @@ vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({
...(await importOriginal<typeof AttachmentUploadModule>()),
resolveNativeChatAttachmentOwner: mocks.resolveNativeChatAttachmentOwner,
resolveNativeChatAttachmentOwnerForWorktree: mocks.resolveNativeChatAttachmentOwnerForWorktree,
uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths,
prepareNativeChatSessionAttachmentUpload: mocks.prepareNativeChatSessionAttachmentUpload,
uploadNativeChatSessionAttachmentPaths: mocks.uploadNativeChatSessionAttachmentPaths
}))
@@ -175,7 +173,6 @@ describe('useNativeChatExternalAttachments', () => {
expect(attachResolvedPaths).toHaveBeenCalledWith(['/local/a.txt'], undefined, {
destinationIsCurrent: expect.any(Function)
})
expect(mocks.uploadNativeChatAttachmentPaths).not.toHaveBeenCalled()
})
it('rejects a whole batch over the drop path limit, like a drop does', async () => {
@@ -192,7 +189,7 @@ describe('useNativeChatExternalAttachments', () => {
})
it('reports a remote runtime before the path limit, since trimming would not help', async () => {
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'runtime' })
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'remote' })
const setNotice = vi.fn()
const probe = await renderProbe({ attachResolvedPaths: vi.fn(), setNotice })
await act(async () => {
@@ -238,7 +235,7 @@ describe('useNativeChatExternalAttachments', () => {
it('does not attach local paths when the owner changes during the file check', async () => {
const fileCheck = deferred<void>()
let owner: { kind: 'local' } | { kind: 'runtime' } = { kind: 'local' }
let owner: { kind: 'local' } | { kind: 'remote' } = { kind: 'local' }
mocks.resolveNativeChatAttachmentOwner.mockImplementation(() => owner)
mocks.stat.mockReturnValueOnce(fileCheck.promise)
const attachResolvedPaths = vi.fn()
@@ -249,7 +246,7 @@ describe('useNativeChatExternalAttachments', () => {
})
act(() => probe.latest().attachExternalPaths(['/external/a.png', '/external/b.png']))
owner = { kind: 'runtime' }
owner = { kind: 'remote' }
await act(async () => fileCheck.resolve())
expect(attachResolvedPaths).not.toHaveBeenCalled()
@@ -264,7 +261,7 @@ describe('useNativeChatExternalAttachments', () => {
// and a path attached to a host that no longer owns it.
it('reports a one-file drop whose owner changes during its file check', async () => {
const fileCheck = deferred<void>()
let owner: { kind: 'local' } | { kind: 'runtime' } = { kind: 'local' }
let owner: { kind: 'local' } | { kind: 'remote' } = { kind: 'local' }
mocks.resolveNativeChatAttachmentOwner.mockImplementation(() => owner)
mocks.stat.mockReturnValueOnce(fileCheck.promise)
const attachResolvedPaths = vi.fn()
@@ -275,7 +272,7 @@ describe('useNativeChatExternalAttachments', () => {
})
act(() => probe.latest().attachExternalPaths(['/external/only.pdf']))
owner = { kind: 'runtime' }
owner = { kind: 'remote' }
await act(async () => fileCheck.resolve())
expect(attachResolvedPaths).not.toHaveBeenCalled()
@@ -308,106 +305,6 @@ describe('useNativeChatExternalAttachments', () => {
)
})
// The upload window is the long one: the paths go to the remote worktree the
// attach captured, so a pane that moved workspaces meanwhile must not receive
// remote paths that live under the workspace it left.
it('does not attach uploaded paths when the pane changes workspace during upload', async () => {
const sshOwner = {
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
} as const
mocks.resolveNativeChatAttachmentOwnerForWorktree.mockReturnValue(sshOwner)
const upload = deferred<string[]>()
mocks.uploadNativeChatAttachmentPaths.mockReturnValueOnce(upload.promise)
const attachResolvedPaths = vi.fn()
const notices: (string | null)[] = []
const probe = await renderProbe({
structuredWorktreeId: 'worktree-1',
attachResolvedPaths,
setNotice: (notice) => notices.push(notice)
})
act(() => probe.latest().attachExternalPaths(['/local/a.txt']))
await probe.setStructuredWorktreeId('worktree-2')
await act(async () => upload.resolve(['/remote/wt/.orca/drops/a.txt']))
expect(attachResolvedPaths).not.toHaveBeenCalled()
expect(notices.at(-1)).toBe(
'This workspace changed hosts while attaching — drop the files again.'
)
})
it('uploads SSH worktree paths and attaches the remote results', async () => {
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
mocks.uploadNativeChatAttachmentPaths.mockResolvedValue(['/remote/wt/.orca/drops/a.txt'])
const attachResolvedPaths = vi.fn()
const probe = await renderProbe({ attachResolvedPaths })
await act(async () => {
probe.latest().attachExternalPaths(['/local/a.txt'])
})
expect(mocks.uploadNativeChatAttachmentPaths).toHaveBeenCalledWith(['/local/a.txt'], {
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/wt/.orca/drops/a.txt'], 'conn-1', {
destinationIsCurrent: expect.any(Function)
})
expect(mocks.stat).not.toHaveBeenCalled()
})
it('delivers concurrent SSH resolutions in order without deduplicating paths', async () => {
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
const firstUpload = deferred<string[]>()
const secondUpload = deferred<string[]>()
mocks.uploadNativeChatAttachmentPaths
.mockReturnValueOnce(firstUpload.promise)
.mockReturnValueOnce(secondUpload.promise)
const attachResolvedPaths = vi.fn()
const probe = await renderProbe({ attachResolvedPaths })
act(() => {
probe.latest().attachExternalPaths(['/local/a.txt'])
probe.latest().attachExternalPaths(['/local/b.txt'])
})
await act(async () => {
secondUpload.resolve(['/remote/wt/.orca/drops/b.txt', '/remote/wt/.orca/drops/b.txt'])
})
await act(async () => {
firstUpload.resolve(['/remote/wt/.orca/drops/a.txt'])
})
expect(attachResolvedPaths.mock.calls).toEqual([
[
['/remote/wt/.orca/drops/b.txt', '/remote/wt/.orca/drops/b.txt'],
'conn-1',
{ destinationIsCurrent: expect.any(Function) }
],
[['/remote/wt/.orca/drops/a.txt'], 'conn-1', { destinationIsCurrent: expect.any(Function) }]
])
})
it('shows the not-ready notice instead of attaching unresolved paths', async () => {
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'not-ready' })
const attachResolvedPaths = vi.fn()
@@ -421,7 +318,7 @@ describe('useNativeChatExternalAttachments', () => {
})
it('does not attach client-local paths to a remote runtime', async () => {
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'runtime' })
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'remote' })
const attachResolvedPaths = vi.fn()
const setNotice = vi.fn()
const probe = await renderProbe({ attachResolvedPaths, setNotice })
@@ -445,63 +342,6 @@ describe('useNativeChatExternalAttachments', () => {
expect(attachResolvedPaths).not.toHaveBeenCalled()
})
it('drops an upload that resolves after the composer became disabled', async () => {
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({
kind: 'ssh',
connectionId: 'conn-1',
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1',
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
})
let resolveUpload: (paths: string[]) => void = () => {}
mocks.uploadNativeChatAttachmentPaths.mockReturnValue(
new Promise<string[]>((resolve) => {
resolveUpload = resolve
})
)
const attachResolvedPaths = vi.fn()
const probe = await renderProbe({ attachResolvedPaths })
await act(async () => {
probe.latest().attachExternalPaths(['/local/a.txt'])
})
await probe.setDisabled(true)
await act(async () => {
resolveUpload(['/remote/wt/.orca/drops/a.txt'])
})
expect(attachResolvedPaths).not.toHaveBeenCalled()
})
it('drops an upload that resolves after the SSH owner generation changes', async () => {
const initialOwner = {
kind: 'ssh' as const,
connectionId: 'conn-1',
worktreePath: '/remote/wt',
expectedExecutionHostId: 'ssh:conn-1' as const,
expectedSshTargetId: 'conn-1',
expectedSshConnectionGeneration: 4
}
mocks.resolveNativeChatAttachmentOwner
.mockReturnValueOnce(initialOwner)
.mockReturnValue({ ...initialOwner, expectedSshConnectionGeneration: 5 })
const upload = deferred<string[]>()
mocks.uploadNativeChatAttachmentPaths.mockReturnValue(upload.promise)
const attachResolvedPaths = vi.fn()
const notices: (string | null)[] = []
const probe = await renderProbe({
attachResolvedPaths,
setNotice: (notice) => notices.push(notice)
})
act(() => probe.latest().attachExternalPaths(['/local/a.txt']))
await act(async () => upload.resolve(['/remote/wt/.orca/drops/a.txt']))
expect(attachResolvedPaths).not.toHaveBeenCalled()
expect(notices.at(-1)).toBe(
'This workspace changed hosts while attaching — drop the files again.'
)
})
describe('a structured chat on a paired server', () => {
const session = { sessionId: 'session-1', runtimeEnvironmentId: 'env-1' }
const target = {
@@ -14,7 +14,6 @@ import {
resolveNativeChatAttachmentOwner,
resolveNativeChatAttachmentOwnerForWorktree,
resolveNativeChatRuntimeSessionAttachmentOwner,
uploadNativeChatAttachmentPaths,
type NativeChatAttachmentOwner,
type NativeChatStructuredAttachmentSession
} from './native-chat-attachment-upload'
@@ -149,7 +148,7 @@ export function useNativeChatExternalAttachments({
setNotice(nativeChatWorktreeNotReadyNotice())
return
}
if (owner.kind === 'runtime') {
if (owner.kind === 'remote') {
setNotice(nativeChatLocalAttachmentUnsupportedNotice())
return
}
@@ -173,24 +172,6 @@ export function useNativeChatExternalAttachments({
})
return
}
if (owner.kind === 'ssh') {
const remotePaths = await uploadNativeChatAttachmentPaths(paths, owner)
if (disabledRef.current || !mountedRef.current) {
return
}
if (!ownerStillCurrent()) {
setNotice(nativeChatAttachmentOwnerChangedNotice())
return
}
if (!remotePaths || remotePaths.length === 0) {
setNotice(nativeChatAttachmentUnreadableNotice())
return
}
attachResolvedPaths(remotePaths, owner.connectionId, {
destinationIsCurrent: ownerStillCurrent
})
return
}
const readablePaths: string[] = []
for (const targetPath of paths) {
if (disabledRef.current || !mountedRef.current) {
@@ -43,32 +43,15 @@ afterEach(async () => {
window.api = previousApi
})
async function fixture(
kind: 'local' | 'ssh',
acceptsImages = true,
source: 'menu' | 'event' = 'menu'
) {
async function fixture(acceptsImages = true, source: 'menu' | 'event' = 'menu') {
const save = Promise.withResolvers<string | null>()
const thumbnail = Promise.withResolvers<{
dataUrl: string
width: number
height: number
} | null>()
const owner: NativeChatAttachmentOwner =
kind === 'ssh'
? {
kind: 'ssh',
connectionId: 'ssh-1',
worktreePath: '/remote/folder',
expectedExecutionHostId: 'ssh:ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 1
}
: { kind: 'local' }
const path =
kind === 'ssh'
? '/tmp/orca-paste-1-image.png'
: '/local/native-chat-pastes/orca-paste-1-image.png'
const owner: NativeChatAttachmentOwner = { kind: 'local' }
const path = '/local/native-chat-pastes/orca-paste-1-image.png'
const saveCall = vi.fn(() => save.promise)
Object.defineProperty(window, 'api', {
configurable: true,
@@ -129,29 +112,22 @@ async function fixture(
}
})
await vi.waitFor(() =>
expect(saveCall).toHaveBeenCalledExactlyOnceWith(
kind === 'ssh' ? { connectionId: 'ssh-1' } : { forNativeChatDraft: true }
)
expect(saveCall).toHaveBeenCalledExactlyOnceWith({ forNativeChatDraft: true })
)
return { view, save, thumbnail, path }
}
const referencePastes = [
{ kind: 'local', source: 'menu' },
{ kind: 'ssh', source: 'menu' },
{ kind: 'local', source: 'event' },
{ kind: 'ssh', source: 'event' }
] as const
const referencePastes = [{ source: 'menu' }, { source: 'event' }] as const
it.each(
referencePastes.flatMap((paste) =>
[true, false].map((acceptsImages) => ({ ...paste, acceptsImages }))
)
)(
'can abandon stalled $kind $source operations after reopen with acceptsImages=$acceptsImages',
async ({ kind, source, acceptsImages }) => {
'can abandon stalled $source operations after reopen with acceptsImages=$acceptsImages',
async ({ source, acceptsImages }) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
const { view, save, thumbnail, path } = await fixture(kind, acceptsImages, source)
const { view, save, thumbnail, path } = await fixture(acceptsImages, source)
view.unmount()
const reopened = renderHook(() =>
useNativeChatComposerAttachments({
@@ -183,78 +159,66 @@ it.each(
expect(readNativeChatComposerDraft(scope('menu')).text).toBe('')
}
)
it.each(referencePastes)('keeps a $source paste as a reference after close', async ({ source }) => {
const { view, save, thumbnail, path } = await fixture(false, source)
await act(async () =>
thumbnail.resolve({ dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 })
)
expect(view.result.current.attachments.imageAttachments).toHaveLength(1)
view.unmount()
await act(async () => save.resolve(path))
expect(nativeChatPendingAttachmentSnapshot(scope('menu'))).toEqual([])
expect(readNativeChatComposerDraft(scope('menu')).text).toContain(path)
expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([])
})
it.each(referencePastes)(
'keeps a $kind $source paste as a reference after close',
async ({ kind, source }) => {
const { view, save, thumbnail, path } = await fixture(kind, false, source)
await act(async () =>
thumbnail.resolve({ dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 })
)
expect(view.result.current.attachments.imageAttachments).toHaveLength(1)
'keeps mounted $source paste references without image chips',
async ({ source }) => {
const { save, thumbnail, path } = await fixture(false, source)
await act(async () => thumbnail.resolve(null))
await act(async () => save.resolve(path))
expect(readNativeChatComposerDraft(scope('menu')).text).toContain(path)
expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([])
}
)
it('preserves menu uploads closed before a delayed or absent thumbnail', async () => {
let completed = 0
for (const preview of ['delayed', 'missing', 'present'] as const) {
const { view, save, thumbnail, path } = await fixture()
if (preview !== 'delayed') {
await act(async () =>
thumbnail.resolve(
preview === 'present'
? { dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 }
: null
)
)
}
view.unmount()
await act(async () => save.resolve(path))
expect(nativeChatPendingAttachmentSnapshot(scope('menu'))).toEqual([])
expect(readNativeChatComposerDraft(scope('menu')).text).toContain(path)
expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([])
}
)
it.each(referencePastes)(
'keeps mounted $kind $source paste references without image chips',
async ({ kind, source }) => {
const { save, thumbnail, path } = await fixture(kind, false, source)
await act(async () => thumbnail.resolve(null))
await act(async () => save.resolve(path))
expect(readNativeChatComposerDraft(scope('menu')).text).toContain(path)
expect(readNativeChatComposerDraft(scope('menu')).images).toEqual([])
}
)
it.each(['local', 'ssh'] as const)(
'preserves menu %s uploads closed before a delayed or absent thumbnail',
async (kind) => {
let completed = 0
for (const preview of ['delayed', 'missing', 'present'] as const) {
const { view, save, thumbnail, path } = await fixture(kind)
if (preview !== 'delayed') {
await act(async () =>
thumbnail.resolve(
preview === 'present'
? { dataUrl: 'data:image/png;base64,AA', width: 1, height: 1 }
: null
)
)
}
view.unmount()
await act(async () => save.resolve(path))
expect(nativeChatPendingAttachmentSnapshot(scope('menu'))).toEqual([])
expect(readNativeChatComposerDraft(scope('menu')).images).toHaveLength(++completed)
expect(readNativeChatComposerDraft(scope('menu')).images.at(-1)).toMatchObject({
path,
...(kind === 'ssh' ? { connectionId: 'ssh-1' } : {})
})
// The save also settles when a thumbnail has not answered at all.
if (preview === 'delayed') {
await act(async () => thumbnail.resolve(null))
}
expect(readNativeChatComposerDraft(scope('menu')).images).toHaveLength(++completed)
expect(readNativeChatComposerDraft(scope('menu')).images.at(-1)).toMatchObject({ path })
// The save also settles when a thumbnail has not answered at all.
if (preview === 'delayed') {
await act(async () => thumbnail.resolve(null))
}
}
)
})
it.each(['local', 'ssh'] as const)(
'settles menu %s images without a thumbnail while mounted',
async (kind) => {
const { save, thumbnail, path } = await fixture(kind)
await act(async () => thumbnail.resolve(null))
await act(async () => save.resolve(path))
expect(readNativeChatComposerDraft(scope('menu')).images[0]).toMatchObject({ path })
}
)
it('settles menu images without a thumbnail while mounted', async () => {
const { save, thumbnail, path } = await fixture()
await act(async () => thumbnail.resolve(null))
await act(async () => save.resolve(path))
expect(readNativeChatComposerDraft(scope('menu')).images[0]).toMatchObject({ path })
})
it.each([true, false])(
'ends pending operations for acceptsImages=%s on removal, deletion or failure',
async (acceptsImages) => {
for (const end of ['remove', 'workspace', 'failure'] as const) {
const { view, save, thumbnail, path } = await fixture('local', acceptsImages)
const { view, save, thumbnail, path } = await fixture(acceptsImages)
if (end === 'remove') {
await act(async () => view.result.current.attachments.clearImageAttachments())
} else if (end === 'workspace') {
@@ -31,14 +31,6 @@ const WORKSPACE: NativeChatComposerDraftOwner = {
}
const owners: NativeChatAttachmentOwner[] = [
{ kind: 'local' },
{
kind: 'ssh',
connectionId: 'ssh-1',
worktreePath: '/remote/worktree',
expectedExecutionHostId: 'ssh:ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 1
},
{ kind: 'runtime-session', environmentId: 'paired', sessionId: 'a', pairingRevision: 1 }
]
@@ -87,11 +79,7 @@ it.each(owners)('keeps a $kind upload when its composer closes', (owner) => {
).toBe(true)
expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toEqual([])
expect(readNativeChatComposerDraft(scope('a')).images).toEqual([
{
id: 'upload',
path: '/stored/image.png',
...(owner.kind === 'ssh' ? { connectionId: 'ssh-1' } : {})
}
{ id: 'upload', path: '/stored/image.png' }
])
expect(readNativeChatComposerDraft(scope('b')).images).toEqual([])
})
@@ -143,11 +143,7 @@ export function useNativeChatPasteLifetime(args: {
}
lifetime.uploads.delete(pendingId)
if (saved.status === 'saved' && saved.tempPath) {
resolvePendingImageAttachment(
pendingId,
saved.tempPath,
owner?.kind === 'ssh' ? owner.connectionId : null
)
resolvePendingImageAttachment(pendingId, saved.tempPath, null)
} else {
dropPendingImageAttachment(pendingId)
}
@@ -13,6 +13,7 @@ import {
nativeChatWorktreeNotReadyNotice
} from './native-chat-attachment-upload'
import { findTerminalTabWorktreeId } from './native-chat-file-link'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import {
nativeChatAttachmentOwnerUnchanged,
nativeChatWorkspaceAttachmentMismatchNotice,
@@ -153,9 +154,12 @@ export function useNativeChatWorkspaceFileDrop({
)
}
attachResolvedPaths(dragPaths.paths, owner.kind === 'ssh' ? owner.connectionId : undefined, {
targetOwnerIsCurrent
})
const targetHost = parseExecutionHostId(targetExecutionHostId)
attachResolvedPaths(
dragPaths.paths,
targetHost?.kind === 'ssh' ? targetHost.targetId : undefined,
{ targetOwnerIsCurrent }
)
},
[attachResolvedPaths, disabled, setNotice, structuredWorktreeId, terminalTabId]
)
@@ -45,28 +45,19 @@ describe('file explorer add project action', () => {
expect(canShowAddAsProjectAction(folderNode, null)).toBe(false)
})
it('preserves the selected folder path and active SSH connection', () => {
it('hides the action in an SSH-owned folder, which only a paired server can add from', () => {
expect(
buildAddProjectFromFolderModalData(folderNode, {
canShowAddAsProjectAction(folderNode, {
...folderRepo,
connectionId: 'ssh-target-1'
connectionId: 'ssh-1'
})
).toEqual({
folderPath: '/projects/child-project',
connectionId: 'ssh-target-1'
})
})
it('routes an execution-host-only SSH project subfolder to its SSH target', () => {
).toBe(false)
expect(
buildAddProjectFromFolderModalData(folderNode, {
canShowAddAsProjectAction(folderNode, {
...folderRepo,
executionHostId: 'ssh:ssh-target-1'
executionHostId: 'ssh:ssh-1'
})
).toEqual({
folderPath: '/projects/child-project',
connectionId: 'ssh-target-1'
})
).toBe(false)
})
it('routes a local project subfolder explicitly to local', () => {
@@ -5,12 +5,15 @@ import type { TreeNode } from './file-explorer-types'
export type AddProjectFromFolderModalData = {
folderPath: string
connectionId?: string
runtimeEnvironmentId?: string | null
runtimeEnvironmentId: string | null
}
// Why not an `ssh:` folder: only a paired server adds projects on an SSH host.
export function canShowAddAsProjectAction(node: TreeNode, activeRepo: Repo | null): boolean {
return node.isDirectory && Boolean(activeRepo && isFolderRepo(activeRepo))
if (!node.isDirectory || !activeRepo || !isFolderRepo(activeRepo)) {
return false
}
return parseExecutionHostId(getRepoExecutionHostId(activeRepo))?.kind !== 'ssh'
}
export function buildAddProjectFromFolderModalData(
@@ -19,9 +22,6 @@ export function buildAddProjectFromFolderModalData(
): AddProjectFromFolderModalData {
// Why: subfolder paths must stay on their owning repo host, not the mutable global selection.
const host = parseExecutionHostId(getRepoExecutionHostId(activeRepo))
if (host?.kind === 'ssh') {
return { folderPath: node.path, connectionId: host.targetId }
}
return {
folderPath: node.path,
runtimeEnvironmentId: host?.kind === 'runtime' ? host.environmentId : null
@@ -22,7 +22,6 @@ const mocks = vi.hoisted(() => ({
clearOrcaHookTrustForRepo: vi.fn(),
repos: [] as Repo[]
},
addRemote: vi.fn(),
toastSuccess: vi.fn(),
finishProjectAddWithDefaultCheckout: vi.fn()
}))
@@ -133,13 +132,7 @@ describe('AddProjectFromFolderDialog', () => {
mocks.state.modalData = { folderPath: '/projects/child' }
mocks.state.repos = []
mocks.state.fetchWorktrees.mockResolvedValue(true)
vi.stubGlobal('window', {
api: {
repos: {
addRemote: mocks.addRemote
}
}
})
vi.stubGlobal('window', { api: { repos: {} } })
})
it('adds a local Git folder and opens the default checkout', async () => {
@@ -219,40 +212,6 @@ describe('AddProjectFromFolderDialog', () => {
})
})
it('adds an SSH Git folder through the remote repo import path', async () => {
const repo = makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })
mocks.state.modalData = {
folderPath: '/srv/projects/child',
connectionId: 'ssh-target-1'
}
mocks.addRemote.mockResolvedValue({ repo })
const { default: AddProjectFromFolderDialog } = await import('./AddProjectFromFolderDialog')
renderToStaticMarkup(<AddProjectFromFolderDialog />)
await clickAddProject()
expect(mocks.addRemote).toHaveBeenCalledWith({
connectionId: 'ssh-target-1',
remotePath: '/srv/projects/child'
})
expect(mocks.state.repos).toEqual([{ ...repo, executionHostId: 'ssh:ssh-target-1' }])
expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true,
executionHostId: 'ssh:ssh-target-1'
})
expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledWith({
repoId: repo.id,
source: 'ssh_remote_path',
selectedPath: '/srv/projects/child',
executionHostId: 'ssh:ssh-target-1',
closeModal: mocks.state.closeModal,
setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('Project added on SSH host', {
description: repo.displayName
})
})
it('falls back to completion when Git worktree refresh is not authoritative', async () => {
const repo = makeRepo()
mocks.state.addRepoPath.mockResolvedValue(repo)
@@ -300,29 +259,4 @@ describe('AddProjectFromFolderDialog', () => {
expect(mocks.finishProjectAddWithDefaultCheckout).not.toHaveBeenCalled()
})
it('sends SSH non-Git folders to the Open as Folder confirmation with the connection id', async () => {
mocks.state.modalData = {
folderPath: '/srv/projects/docs',
connectionId: 'ssh-target-1'
}
mocks.addRemote.mockResolvedValue({
error: 'Not a valid git repository: /srv/projects/docs'
})
const { default: AddProjectFromFolderDialog } = await import('./AddProjectFromFolderDialog')
renderToStaticMarkup(<AddProjectFromFolderDialog />)
await clickAddProject()
expect(mocks.addRemote).toHaveBeenCalledWith({
connectionId: 'ssh-target-1',
remotePath: '/srv/projects/docs'
})
expect(mocks.state.closeModal).toHaveBeenCalled()
expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-non-git-folder', {
folderPath: '/srv/projects/docs',
connectionId: 'ssh-target-1'
})
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,4 @@
import React, { useCallback, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FolderPlus, Loader2 } from 'lucide-react'
import {
Dialog,
@@ -12,11 +11,9 @@ import {
import { Button } from '@/components/ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import type { Repo } from '../../../../shared/repo-types'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { finishProjectAddWithDefaultCheckout } from './project-added-default-checkout'
import { translate } from '@/i18n/i18n'
import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert'
import { worktreeRefreshOptions } from './add-repo-runtime-owner'
const NON_GIT_REPO_ERROR = 'Not a valid git repository'
@@ -38,7 +35,6 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo
const isOpen = activeModal === 'confirm-add-project-from-folder'
const [previousOpen, setPreviousOpen] = useState(isOpen)
const folderPath = typeof modalData.folderPath === 'string' ? modalData.folderPath : ''
const connectionId = typeof modalData.connectionId === 'string' ? modalData.connectionId : ''
const runtimeEnvironmentId =
typeof modalData.runtimeEnvironmentId === 'string' ? modalData.runtimeEnvironmentId : null
@@ -57,12 +53,11 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo
closeModal()
openModal('confirm-non-git-folder', {
folderPath,
...(connectionId ? { connectionId } : {}),
// Absence === local: NonGitFolderDialog coerces a missing/empty
// runtimeEnvironmentId to null, so omitting the spread signals local.
...(runtimeEnvironmentId ? { runtimeEnvironmentId } : {})
})
}, [closeModal, connectionId, folderPath, openModal, runtimeEnvironmentId])
}, [closeModal, folderPath, openModal, runtimeEnvironmentId])
const handleConfirm = useCallback(async () => {
if (!folderPath || isAdding) {
@@ -72,35 +67,9 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo
setIsAdding(true)
setError(null)
try {
let repo: Repo | null
if (connectionId) {
const result = await window.api.repos.addRemote({
connectionId,
remotePath: folderPath
})
if ('error' in result) {
throw new Error(result.error)
}
const upserted = upsertAddedRepoWithProjectHostSetup(result.repo, {
sshConnectionId: connectionId
})
repo = upserted.repo
if (upserted.alreadyPresent) {
useAppStore.getState().clearOrcaHookTrustForRepo(repo.id)
}
if (!mountedRef.current || gen !== addGenRef.current) {
return
}
toast.success(
translate(
'auto.components.sidebar.AddProjectFromFolderDialog.e643b30398',
'Project added on SSH host'
),
{ description: repo.displayName }
)
} else {
repo = await addRepoPath(folderPath, 'git', { runtimeEnvironmentId })
}
const repo = await addRepoPath(folderPath, 'git', {
runtimeEnvironmentId
})
if (!mountedRef.current || gen !== addGenRef.current) {
return
@@ -114,18 +83,14 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo
}
// Why: after the repo is already added, a non-authoritative refresh
// should still close onto the project row instead of trapping the user.
const ownerOptions = worktreeRefreshOptions(runtimeEnvironmentId, connectionId)
const ownerOptions = worktreeRefreshOptions(runtimeEnvironmentId)
await fetchWorktrees(repo.id, ownerOptions)
if (!mountedRef.current || gen !== addGenRef.current) {
return
}
await finishProjectAddWithDefaultCheckout({
repoId: repo.id,
source: connectionId
? 'ssh_remote_path'
: runtimeEnvironmentId
? 'runtime_server_path'
: 'local_folder_picker',
source: runtimeEnvironmentId ? 'runtime_server_path' : 'local_folder_picker',
selectedPath: folderPath,
executionHostId: ownerOptions.executionHostId,
closeModal,
@@ -150,7 +115,6 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo
}, [
addRepoPath,
closeModal,
connectionId,
fetchWorktrees,
folderPath,
isAdding,
@@ -14,7 +14,6 @@ type CloneStepProps = {
isCloning: boolean
disableDestinationPicker?: boolean
runtimeEnvironmentId?: string | null
sshTargetId?: string | null
cloneTargetLabel?: string | null
/** No host resolved yet (its server is still coming up): never clone onto this computer. */
submitDisabled?: boolean
@@ -32,7 +31,6 @@ export function CloneStep({
isCloning,
disableDestinationPicker = false,
runtimeEnvironmentId,
sshTargetId,
cloneTargetLabel,
onUrlChange,
onDestChange,
@@ -41,8 +39,7 @@ export function CloneStep({
submitDisabled = false
}: CloneStepProps): React.JSX.Element {
const [browsingDestination, setBrowsingDestination] = useState(false)
const isRemoteClone = Boolean(runtimeEnvironmentId || sshTargetId)
const canBrowseRemoteDestination = isRemoteClone
const canBrowseRemoteDestination = Boolean(runtimeEnvironmentId)
const canClone = !!cloneUrl.trim() && !!cloneDestination.trim() && !isCloning
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
@@ -53,7 +50,7 @@ export function CloneStep({
}
}
if (browsingDestination && (runtimeEnvironmentId || sshTargetId)) {
if (browsingDestination && runtimeEnvironmentId) {
return (
<>
<DialogHeader>
@@ -67,27 +64,15 @@ export function CloneStep({
)}
</DialogDescription>
</DialogHeader>
{sshTargetId ? (
<RemoteFileBrowser
targetId={sshTargetId}
initialPath={cloneDestination || '~'}
onSelect={(path) => {
onDestChange(path)
setBrowsingDestination(false)
}}
onCancel={() => setBrowsingDestination(false)}
/>
) : (
<RemoteFileBrowser
runtimeEnvironmentId={runtimeEnvironmentId as string}
initialPath={cloneDestination || '~'}
onSelect={(path) => {
onDestChange(path)
setBrowsingDestination(false)
}}
onCancel={() => setBrowsingDestination(false)}
/>
)}
<RemoteFileBrowser
runtimeEnvironmentId={runtimeEnvironmentId}
initialPath={cloneDestination || '~'}
onSelect={(path) => {
onDestChange(path)
setBrowsingDestination(false)
}}
onCancel={() => setBrowsingDestination(false)}
/>
</>
)
}
@@ -141,7 +126,7 @@ export function CloneStep({
onChange={(e) => onDestChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isRemoteClone
runtimeEnvironmentId
? translate(
'auto.components.sidebar.AddRepoSteps.remoteCloneParentPlaceholder',
'/home/user/projects'
@@ -31,7 +31,6 @@ type CreateStepProps = {
parentDefaultPending?: boolean
manualParentEntry?: boolean
runtimeEnvironmentId?: string | null
sshTargetId?: string | null
/** No host resolved yet (its server is still coming up): never create on this computer. */
submitDisabled?: boolean
onNameChange: (value: string) => void
@@ -51,7 +50,6 @@ export function CreateStep({
parentDefaultPending = false,
manualParentEntry = false,
runtimeEnvironmentId,
sshTargetId,
submitDisabled = false,
onNameChange,
onParentChange,
@@ -59,11 +57,11 @@ export function CreateStep({
onCreate
}: CreateStepProps): React.JSX.Element {
const [browsingParent, setBrowsingParent] = useState(false)
// Why: SSH hosts need a typed remote path; hiding that field behind the
// Why: server hosts need a typed host path; hiding that field behind the
// collapsed defaults makes the create flow look impossible.
const [advancedOpen, setAdvancedOpen] = useState(manualParentEntry)
// Why: SSH hosts report "unknown"; only a confirmed Git miss should block
// Why: a failed probe reports "unknown"; only a confirmed Git miss should block
// Git-only creation.
const canSubmit =
createName.trim().length > 0 &&
@@ -81,7 +79,7 @@ export function CreateStep({
'auto.components.sidebar.AddRepoCreateStep.6ed14c0281',
'host folder not selected'
)
const isRemoteHost = Boolean(runtimeEnvironmentId || sshTargetId)
const isRemoteHost = Boolean(runtimeEnvironmentId)
const summaryParent = useMemo(
() =>
@@ -115,11 +113,10 @@ export function CreateStep({
const showRuntimeMissingParent =
runtimeEnvironmentId && !createParent.trim() && runtimeParentStatus !== 'checking'
if (browsingParent && (runtimeEnvironmentId || sshTargetId)) {
if (browsingParent && runtimeEnvironmentId) {
return (
<CreateProjectParentBrowser
runtimeEnvironmentId={runtimeEnvironmentId}
sshTargetId={sshTargetId}
createParent={createParent}
onParentChange={onParentChange}
onClose={() => setBrowsingParent(false)}
@@ -243,7 +240,6 @@ export function CreateStep({
isCreating={isCreating}
manualParentEntry={manualParentEntry}
runtimeEnvironmentId={runtimeEnvironmentId}
sshTargetId={sshTargetId}
onParentChange={onParentChange}
onPickParent={onPickParent}
onBrowseServer={() => setBrowsingParent(true)}
@@ -39,8 +39,7 @@ export default React.memo(function AddRepoDialog({
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
const settings = useAppStore((s) => s.settings)
const { closeModal, closeForFolderHandoff, finishProjectAdd, handleOpenSshSettings } =
useAddRepoHostedController(hosted)
const { closeModal, closeForFolderHandoff, finishProjectAdd } = useAddRepoHostedController(hosted)
const [step, setStep] = useState<AddRepoDialogStep>('add')
const [isAdding, setIsAdding] = useState(false)
const [addProjectBusyLabel, setAddProjectBusyLabel] = useState<string | null>(null)
@@ -68,8 +67,6 @@ export default React.memo(function AddRepoDialog({
setActiveNestedScanId,
handleStopNestedScan,
resetNestedRepoReviewState,
showRemoteNestedRepoReview,
trackRemoteNestedScanResult,
handleImportNestedRepos,
handleOpenNestedRootFolder,
resetNestedImportFlow,
@@ -91,24 +88,19 @@ export default React.memo(function AddRepoDialog({
remotePath,
remoteError,
isAddingRemote,
isScanningNested: isScanningRemoteNested,
setSelectedTargetId,
setRemotePath,
setRemoteError,
resetRemoteState,
handleOpenRemoteStep,
handleAddRemoteRepo,
handleConnectTarget,
stopRemoteNestedScan
handleConnectTarget
} = useRemoteRepo(
fetchWorktrees,
setStep,
// Why: useRemoteRepo closes only for the non-git → confirm-dialog handoff.
closeForFolderHandoff,
(repoId, executionHostId) => completeGitRepoAdd(repoId, 'ssh_remote_path', executionHostId),
scanNestedRepos,
showRemoteNestedRepoReview,
trackRemoteNestedScanResult,
selectedRuntimeEnvironmentId
)
const {
@@ -128,8 +120,7 @@ export default React.memo(function AddRepoDialog({
(repoId, executionHostId) => completeGitRepoAdd(repoId, 'create_project', executionHostId),
{
hostId: hostSelection.selectedHostId,
runtimeEnvironmentId: selectedRuntimeEnvironmentId,
sshTargetId: hostSelection.selectedSshTargetId
runtimeEnvironmentId: selectedRuntimeEnvironmentId
}
)
@@ -143,7 +134,6 @@ export default React.memo(function AddRepoDialog({
} = useCreateProjectDefaults({
step,
activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId,
sshTargetId: hostSelection.selectedSshTargetId,
createParent,
setCreateParent
})
@@ -164,7 +154,6 @@ export default React.memo(function AddRepoDialog({
step,
hostId: hostSelection.selectedHostId,
activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId,
sshTargetId: hostSelection.selectedSshTargetId,
workspaceDir: settings?.workspaceDir,
fetchWorktrees,
onGitRepoReady: completeGitRepoAdd
@@ -294,7 +283,6 @@ export default React.memo(function AddRepoDialog({
step={step}
isRuntimeEnvironmentActive={isRuntimeEnvironmentActive}
activeRuntimeEnvironmentId={selectedRuntimeEnvironmentId}
isSshLikely={false}
repoCount={repos.length}
isAdding={isAdding}
addProjectBusyLabel={addProjectBusyLabel}
@@ -309,16 +297,13 @@ export default React.memo(function AddRepoDialog({
isCloning={isCloning}
sshTargets={sshTargets}
selectedTargetId={selectedTargetId}
selectedSshTargetId={hostSelection.selectedSshTargetId}
selectedHostLabel={
hostSelection.hostOptions.find((host) => host.id === hostSelection.selectedHostId)
?.label ?? null
}
lockSshTargetSelection={hostSelection.selectedParsedHost?.kind === 'ssh'}
remotePath={remotePath}
remoteError={remoteError}
isAddingRemote={isAddingRemote}
isScanningRemoteNested={isScanningRemoteNested}
nestedScan={nestedScan}
nestedSelectedPaths={nestedSelectedPaths}
nestedGroupName={nestedGroupName}
@@ -327,19 +312,17 @@ export default React.memo(function AddRepoDialog({
createError={createError}
isCreating={isCreating}
hostSelector={<AddRepoHostSelectorSlot hostSelection={hostSelection} />}
showRemoteAction={false}
actionsDisabled={!hostSelection.selectedHostId}
browseHostKind={selectedHostKind ?? 'runtime'}
browseHostKind={selectedHostKind === 'local' ? 'local' : 'runtime'}
createDefaultParent={createDefaultParent}
createGitAvailability={createGitAvailability}
createRuntimeParentStatus={createRuntimeParentStatus}
createParentDefaultPending={createParentDefaultPending}
manualCreateParentEntry={isRuntimeEnvironmentActive || selectedHostKind === 'ssh'}
manualCreateParentEntry={isRuntimeEnvironmentActive}
onBrowse={() =>
routeAddRepoBrowse(hostSelection.selectedParsedHost, {
browseLocal: () => void handleBrowse(),
browseRuntime: () => setStep('server-path'),
browseSsh: (targetId) => void handleOpenRemoteStep(targetId)
browseRuntime: () => setStep('server-path')
})
}
onOpenCloneStep={() => {
@@ -369,9 +352,7 @@ export default React.memo(function AddRepoDialog({
setRemoteError(null)
}}
onAddRemoteRepo={handleAddRemoteRepo}
onOpenSshSettings={handleOpenSshSettings}
onConnectTarget={handleConnectTarget}
onStopRemoteNestedScan={stopRemoteNestedScan}
onCloneUrlChange={(value) => {
setCloneUrl(value)
setCloneError(null)
@@ -29,7 +29,6 @@ function renderStepContent(overrides: Partial<StepContentProps>): string {
step: 'nested',
isRuntimeEnvironmentActive: false,
activeRuntimeEnvironmentId: null,
isSshLikely: false,
repoCount: 1,
isAdding: false,
addProjectBusyLabel: null,
@@ -47,7 +46,6 @@ function renderStepContent(overrides: Partial<StepContentProps>): string {
remotePath: '',
remoteError: null,
isAddingRemote: false,
isScanningRemoteNested: false,
nestedScan,
nestedSelectedPaths: new Set(nestedScan.repos.map((repo) => repo.path)),
nestedGroupName: 'platform',
@@ -69,9 +67,7 @@ function renderStepContent(overrides: Partial<StepContentProps>): string {
onSelectTarget: vi.fn(),
onRemotePathChange: vi.fn(),
onAddRemoteRepo: vi.fn(),
onOpenSshSettings: vi.fn(),
onConnectTarget: vi.fn(),
onStopRemoteNestedScan: vi.fn(),
onCloneUrlChange: vi.fn(),
onCloneDestinationChange: vi.fn(),
onPickCloneDestination: vi.fn(),
@@ -141,21 +137,6 @@ describe('AddRepoDialogStepContent nested imports', () => {
expect(html).toContain('host folder not selected')
})
it('uses manual path entry for SSH create project locations', () => {
const html = renderStepContent({
step: 'create',
manualCreateParentEntry: true,
selectedSshTargetId: 'openclaw-2',
activeRuntimeEnvironmentId: null
})
expect(html).toContain('Create a new project')
expect(html).toContain('placeholder="/home/user/projects"')
expect(html).toContain('aria-label="Browse host filesystem"')
expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*aria-label="Browse host filesystem"/)
expect(html).not.toContain('Choose parent folder')
})
it('offers host browsing for remote clone destinations', () => {
const html = renderStepContent({
step: 'clone',
@@ -167,102 +148,11 @@ describe('AddRepoDialogStepContent nested imports', () => {
expect(html).toContain('aria-label="Browse host filesystem"')
})
it('offers SSH browsing for selected-host clone destinations', () => {
const html = renderStepContent({
step: 'clone',
selectedSshTargetId: 'openclaw-2',
selectedHostLabel: 'openclaw 2'
})
it('falls back to the start step when no paired server reaches the SSH hosts', () => {
const html = renderStepContent({ step: 'remote', selectedTargetId: 'openclaw-2' })
expect(html).toContain('Clone from URL')
expect(html).toContain('choose where to clone it on openclaw 2')
expect(html).toContain('Parent folder')
expect(html).toContain('aria-label="Browse host filesystem"')
expect(html).not.toContain('aria-label="Choose folder"')
})
it('hides the SSH target chooser after a host was already selected', () => {
const html = renderStepContent({
step: 'remote',
lockSshTargetSelection: true,
selectedTargetId: 'openclaw-2',
sshTargets: [
{
id: 'github',
label: 'github.com',
host: 'github.com',
port: 22,
username: 'git',
state: {
targetId: 'github',
status: 'connected',
error: null,
reconnectAttempt: 0
}
},
{
id: 'openclaw-2',
label: 'openclaw 2',
host: 'openclaw.example.com',
port: 22,
username: 'dev',
state: {
targetId: 'openclaw-2',
status: 'connected',
error: null,
reconnectAttempt: 0
}
}
]
})
expect(html).toContain('Open project on SSH host')
expect(html).toContain('openclaw 2')
expect(html).toContain('Host path')
expect(html).not.toContain('SSH target')
expect(html).not.toContain('github.com')
expect(html).not.toContain('Connect')
})
it('shows a connect affordance for a selected disconnected SSH host', () => {
const html = renderStepContent({
step: 'remote',
lockSshTargetSelection: true,
selectedTargetId: 'openclaw-2',
sshTargets: [
{
id: 'openclaw-2',
label: 'openclaw 2',
host: 'openclaw.example.com',
port: 22,
username: 'dev',
state: {
targetId: 'openclaw-2',
status: 'disconnected',
error: null,
reconnectAttempt: 0
}
}
]
})
expect(html).toContain('openclaw 2')
expect(html).toContain('is disconnected')
expect(html).toContain('Connect')
expect(html).not.toContain('SSH target')
expect(html).toContain('placeholder="/home/user/project"')
expect(html).toContain('disabled=""')
})
it('uses SSH-aware copy on the add step when an SSH host is selected', () => {
const html = renderStepContent({
step: 'add',
browseHostKind: 'ssh'
})
expect(html).toContain('Open project on SSH host')
expect(html).toContain('Existing Git repository or folder on this SSH host')
expect(html).not.toContain('Local project, Git repo, or folder with many repos')
expect(html).toContain('Browse folder')
expect(html).not.toContain('Host path')
})
it('uses the standard add step for remote Orca server hosts', () => {
@@ -15,7 +15,6 @@ type AddRepoDialogStepContentProps = {
step: AddRepoDialogStep
isRuntimeEnvironmentActive: boolean
activeRuntimeEnvironmentId: string | null | undefined
isSshLikely: boolean
repoCount: number
isAdding: boolean
addProjectBusyLabel: string | null
@@ -30,13 +29,10 @@ type AddRepoDialogStepContentProps = {
isCloning: boolean
sshTargets: (SshTarget & { state?: SshConnectionState })[]
selectedTargetId: string | null
selectedSshTargetId?: string | null
selectedHostLabel?: string | null
lockSshTargetSelection?: boolean
remotePath: string
remoteError: string | null
isAddingRemote: boolean
isScanningRemoteNested: boolean
nestedScan: NestedRepoScanResult | null
nestedSelectedPaths: Set<string>
nestedGroupName: string
@@ -45,11 +41,9 @@ type AddRepoDialogStepContentProps = {
createError: string | null
isCreating: boolean
hostSelector?: ReactNode
showRemoteAction?: boolean
canCreateProject?: boolean
actionsDisabled?: boolean
manualCreateParentEntry?: boolean
browseHostKind?: 'local' | 'ssh' | 'runtime'
browseHostKind?: 'local' | 'runtime'
createDefaultParent: string
createGitAvailability: GitAvailability
createRuntimeParentStatus: 'idle' | 'checking' | 'failed'
@@ -64,9 +58,7 @@ type AddRepoDialogStepContentProps = {
onSelectTarget: (id: string) => void
onRemotePathChange: (path: string) => void
onAddRemoteRepo: () => void
onOpenSshSettings: () => void
onConnectTarget: (id: string) => Promise<void>
onStopRemoteNestedScan: () => void
onCloneUrlChange: (url: string) => void
onCloneDestinationChange: (destination: string) => void
onPickCloneDestination: () => void
@@ -85,7 +77,6 @@ export function AddRepoDialogStepContent({
step,
isRuntimeEnvironmentActive,
activeRuntimeEnvironmentId,
isSshLikely,
repoCount,
isAdding,
addProjectBusyLabel,
@@ -100,13 +91,10 @@ export function AddRepoDialogStepContent({
isCloning,
sshTargets,
selectedTargetId,
selectedSshTargetId,
selectedHostLabel,
lockSshTargetSelection = false,
remotePath,
remoteError,
isAddingRemote,
isScanningRemoteNested,
nestedScan,
nestedSelectedPaths,
nestedGroupName,
@@ -115,8 +103,6 @@ export function AddRepoDialogStepContent({
createError,
isCreating,
hostSelector,
showRemoteAction = true,
canCreateProject = true,
actionsDisabled = false,
manualCreateParentEntry = isRuntimeEnvironmentActive,
browseHostKind = 'local',
@@ -134,9 +120,7 @@ export function AddRepoDialogStepContent({
onSelectTarget,
onRemotePathChange,
onAddRemoteRepo,
onOpenSshSettings,
onConnectTarget,
onStopRemoteNestedScan,
onCloneUrlChange,
onCloneDestinationChange,
onPickCloneDestination,
@@ -156,18 +140,17 @@ export function AddRepoDialogStepContent({
selectedHostLabel ?? null,
onOpenRemoteStep
)
if (step === 'add') {
// Why the start step for a remote step without a server: SSH hosts hold projects only through
// the paired server that reaches them, so there is nothing to show if that server went away.
if (step === 'add' || (step === 'remote' && !serverSsh)) {
return (
<AddRepoLocalStartStep
repoCount={repoCount}
isSshLikely={isSshLikely}
isAdding={isAdding}
addProjectBusyLabel={addProjectBusyLabel}
nestedScanInProgress={nestedScanInProgress}
nestedScanId={nestedScanId}
hostSelector={hostSelector}
showRemoteAction={showRemoteAction || Boolean(serverSsh)}
canCreateProject={canCreateProject}
actionsDisabled={actionsDisabled}
browseHostKind={browseHostKind}
serverSshProjects={serverSsh?.state}
@@ -197,23 +180,19 @@ export function AddRepoDialogStepContent({
)
}
if (step === 'remote') {
if (step === 'remote' && serverSsh) {
return (
<RemoteStep
sshTargets={sshTargets}
selectedTargetId={selectedTargetId}
lockSshTargetSelection={lockSshTargetSelection}
remotePath={remotePath}
remoteError={remoteError}
isAddingRemote={isAddingRemote}
isScanningNested={isScanningRemoteNested}
onSelectTarget={onSelectTarget}
onRemotePathChange={onRemotePathChange}
onAdd={onAddRemoteRepo}
onOpenSshSettings={onOpenSshSettings}
onConnectTarget={onConnectTarget}
onStopNestedScan={onStopRemoteNestedScan}
server={serverSsh ?? undefined}
server={serverSsh}
/>
)
}
@@ -228,10 +207,7 @@ export function AddRepoDialogStepContent({
isCloning={isCloning}
disableDestinationPicker={isRuntimeEnvironmentActive}
runtimeEnvironmentId={activeRuntimeEnvironmentId}
sshTargetId={selectedSshTargetId}
cloneTargetLabel={
isRuntimeEnvironmentActive || selectedSshTargetId ? selectedHostLabel : null
}
cloneTargetLabel={isRuntimeEnvironmentActive ? selectedHostLabel : null}
onUrlChange={onCloneUrlChange}
onDestChange={onCloneDestinationChange}
onPickDestination={onPickCloneDestination}
@@ -271,7 +247,6 @@ export function AddRepoDialogStepContent({
parentDefaultPending={createParentDefaultPending}
manualParentEntry={manualCreateParentEntry}
runtimeEnvironmentId={activeRuntimeEnvironmentId}
sshTargetId={selectedSshTargetId}
onNameChange={onCreateNameChange}
onParentChange={onCreateParentChange}
onPickParent={onPickCreateParent}
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { CircleStop, FolderOpen, Plus, Settings } from 'lucide-react'
import { FolderOpen, Plus } from 'lucide-react'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -12,35 +12,31 @@ import { translate } from '@/i18n/i18n'
type RemoteStepProps = {
sshTargets: (SshTarget & { state?: SshConnectionState })[]
selectedTargetId: string | null
lockSshTargetSelection?: boolean
remotePath: string
remoteError: string | null
isAddingRemote: boolean
isScanningNested?: boolean
onSelectTarget: (id: string) => void
onRemotePathChange: (value: string) => void
onAdd: () => void
onOpenSshSettings: () => void
onConnectTarget: (id: string) => Promise<void>
onStopNestedScan?: () => void
/** The paired server whose own SSH hosts are listed; absent for this client's hosts. */
server?: { environmentId: string; label: string; onTargetsChanged: () => void }
/** The paired server whose own SSH hosts are listed. */
server: {
environmentId: string
label: string
onTargetsChanged: () => void
}
}
export function RemoteStep({
sshTargets,
selectedTargetId,
lockSshTargetSelection = false,
remotePath,
remoteError,
isAddingRemote,
isScanningNested,
onSelectTarget,
onRemotePathChange,
onAdd,
onOpenSshSettings,
onConnectTarget,
onStopNestedScan,
server
}: RemoteStepProps): React.JSX.Element {
const [browsing, setBrowsing] = useState(false)
@@ -48,9 +44,6 @@ export function RemoteStep({
const selectedTarget = selectedTargetId
? sshTargets.find((target) => target.id === selectedTargetId)
: null
const selectedTargetLabel =
selectedTarget?.label ||
(selectedTarget ? `${selectedTarget.username}@${selectedTarget.host}` : selectedTargetId)
const selectedTargetStatus = selectedTarget?.state?.status ?? 'disconnected'
const selectedTargetConnected = selectedTargetStatus === 'connected'
@@ -73,7 +66,7 @@ export function RemoteStep({
</DialogHeader>
<RemoteFileBrowser
targetId={selectedTargetId}
runtimeEnvironmentId={server?.environmentId}
runtimeEnvironmentId={server.environmentId}
initialPath={remotePath || '~'}
onSelect={(path) => {
onRemotePathChange(path)
@@ -95,116 +88,57 @@ export function RemoteStep({
)}
</DialogTitle>
<DialogDescription>
{lockSshTargetSelection
? translate(
'auto.components.sidebar.AddRepoRemoteStep.lockedDescription',
'Enter the path to a Git repository on {{value0}}.',
{ value0: selectedTargetLabel ?? 'this SSH target' }
)
: server
? translate(
'auto.components.sidebar.AddRepoRemoteStep.serverDescription',
'Choose an SSH host that {{server}} connects to, and enter the path to a Git repository on it.',
{ server: server.label }
)
: translate(
'auto.components.sidebar.AddRepoRemoteStep.80557be85a',
'Choose a connected SSH target and enter the path to a Git repository.'
)}
{translate(
'auto.components.sidebar.AddRepoRemoteStep.serverDescription',
'Choose an SSH host that {{server}} connects to, and enter the path to a Git repository on it.',
{ server: server.label }
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-1">
{!lockSshTargetSelection ? (
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.AddRepoRemoteStep.44637f43bd', 'SSH target')}
</label>
{sshTargets.length === 0 && server ? (
<p className="py-1 text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoRemoteStep.serverEmpty',
'{{server}} has no SSH hosts yet.',
{ server: server.label }
)}
</p>
) : sshTargets.length === 0 ? (
<div className="space-y-1.5 py-1">
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoRemoteStep.df6fbcf880',
'No SSH targets configured.'
)}
</p>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={onOpenSshSettings}
>
<Settings className="size-3.5" />
{translate(
'auto.components.sidebar.AddRepoRemoteStep.0416bde073',
'Add in Settings'
)}
</Button>
</div>
) : (
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1 scrollbar-sleek">
{sshTargets.map((target) => (
<SshTargetRow
key={target.id}
target={target}
isSelected={selectedTargetId === target.id}
onSelect={onSelectTarget}
onConnect={onConnectTarget}
/>
))}
</div>
)}
{server ? (
<>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setAddingServerHost(true)}
>
<Plus className="size-3.5" />
{translate(
'auto.components.sidebar.AddRepoRemoteStep.serverAddHost',
'Add SSH host'
)}
</Button>
<AddServerSshHostDialog
environmentId={server.environmentId}
serverLabel={server.label}
open={addingServerHost}
onOpenChange={setAddingServerHost}
onSaved={server.onTargetsChanged}
/>
</>
) : null}
</div>
) : selectedTarget && !selectedTargetConnected ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
<p className="min-w-0 text-xs text-muted-foreground">
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.AddRepoRemoteStep.44637f43bd', 'SSH target')}
</label>
{sshTargets.length === 0 ? (
<p className="py-1 text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoRemoteStep.lockedDisconnected',
'{{value0}} is disconnected.',
{ value0: selectedTargetLabel ?? 'This SSH host' }
'auto.components.sidebar.AddRepoRemoteStep.serverEmpty',
'{{server}} has no SSH hosts yet.',
{ server: server.label }
)}
</p>
<Button
variant="outline"
size="xs"
className="shrink-0"
onClick={() => onConnectTarget(selectedTarget.id)}
>
{translate('auto.components.sidebar.AddRepoRemoteStep.93e0221434', 'Connect')}
</Button>
</div>
) : null}
) : (
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1 scrollbar-sleek">
{sshTargets.map((target) => (
<SshTargetRow
key={target.id}
target={target}
isSelected={selectedTargetId === target.id}
onSelect={onSelectTarget}
onConnect={onConnectTarget}
/>
))}
</div>
)}
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setAddingServerHost(true)}
>
<Plus className="size-3.5" />
{translate('auto.components.sidebar.AddRepoRemoteStep.serverAddHost', 'Add SSH host')}
</Button>
<AddServerSshHostDialog
environmentId={server.environmentId}
serverLabel={server.label}
open={addingServerHost}
onOpenChange={setAddingServerHost}
onSaved={server.onTargetsChanged}
/>
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
@@ -257,12 +191,6 @@ export function RemoteStep({
'Add project on SSH host'
)}
</Button>
{isScanningNested ? (
<Button variant="outline" className="w-full" onClick={onStopNestedScan}>
<CircleStop className="size-3.5" />
{translate('auto.components.sidebar.AddRepoRemoteStep.5b205b5281', 'Stop scan')}
</Button>
) : null}
</div>
</>
)
@@ -16,11 +16,10 @@ vi.mock('@/components/ui/dialog', () => ({
DialogTitle: ({ children }: { children: ReactModule.ReactNode }) => <h1>{children}</h1>
}))
function renderLocalStartStep(isSshLikely: boolean): string {
function renderLocalStartStep(): string {
return renderToStaticMarkup(
<AddRepoLocalStartStep
repoCount={1}
isSshLikely={isSshLikely}
isAdding={false}
addProjectBusyLabel={null}
nestedScanInProgress={false}
@@ -59,10 +58,7 @@ type LocalStartStepDomOptions = {
nestedScanId?: string | null
}
async function renderLocalStartStepDom(
isSshLikely: boolean,
options: LocalStartStepDomOptions = {}
): Promise<{
async function renderLocalStartStepDom(options: LocalStartStepDomOptions = {}): Promise<{
container: HTMLDivElement
root: Root
}> {
@@ -75,7 +71,6 @@ async function renderLocalStartStepDom(
<TooltipProvider>
<AddRepoLocalStartStep
repoCount={1}
isSshLikely={isSshLikely}
isAdding={options.isAdding ?? false}
actionsDisabled={options.actionsDisabled ?? false}
addProjectBusyLabel={options.addProjectBusyLabel ?? null}
@@ -104,51 +99,11 @@ function findButton(container: HTMLElement, label: string): HTMLButtonElement {
return button
}
function getActionTitles(isSshLikely: boolean): {
primary: string
secondary: string[]
} {
const { primaryAction, secondaryActions } = getAddRepoLocalStartActions({
isSshLikely,
onBrowse: vi.fn(),
onOpenCloneStep: vi.fn(),
onOpenRemoteStep: vi.fn(),
onOpenCreateStep: vi.fn()
})
return {
primary: primaryAction.title,
secondary: secondaryActions.map((action) => action.title)
}
}
function getHostAwareActionModel(): {
secondary: string[]
createDisabled: boolean | undefined
} {
const { secondaryActions } = getAddRepoLocalStartActions({
isSshLikely: true,
showRemoteAction: false,
onBrowse: vi.fn(),
onOpenCloneStep: vi.fn(),
onOpenRemoteStep: vi.fn(),
onOpenCreateStep: vi.fn()
})
const createAction = secondaryActions.find((action) => action.kind === 'create')
return {
secondary: secondaryActions.map((action) => action.title),
createDisabled: createAction?.disabled
}
}
function getRuntimeHostActionModel(): {
primary: string
description: string
} {
const { primaryAction } = getAddRepoLocalStartActions({
isSshLikely: false,
showRemoteAction: false,
browseHostKind: 'runtime',
onBrowse: vi.fn(),
onOpenCloneStep: vi.fn(),
@@ -168,45 +123,16 @@ describe('AddRepoLocalStartStep', () => {
})
it('promotes browse folder and keeps secondary actions always visible', () => {
const markup = renderLocalStartStep(false)
const markup = renderLocalStartStep()
expect(markup).toContain('Browse folder')
expect(markup).toContain('Clone from URL')
expect(markup).toContain('Project on SSH host')
expect(markup).not.toContain('SSH host')
expect(markup).toContain('Create new project')
expect(markup).toContain('Other ways to add')
expect(markup).not.toContain('More options')
})
it('orders secondary actions clone-first for default users', () => {
const titles = getActionTitles(false)
expect(titles.primary).toBe('Browse folder')
expect(titles.secondary).toEqual([
'Clone from URL',
'Project on SSH host',
'Create new project'
])
})
it('orders secondary actions remote-first for SSH-likely users', () => {
const titles = getActionTitles(true)
expect(titles.primary).toBe('Browse folder')
expect(titles.secondary).toEqual([
'Project on SSH host',
'Clone from URL',
'Create new project'
])
})
it('lets host-aware Add Project replace the separate remote row', () => {
const model = getHostAwareActionModel()
expect(model.secondary).toEqual(['Clone from URL', 'Create new project'])
expect(model.createDisabled).toBe(false)
})
it('uses host-neutral browse copy for runtime hosts', () => {
const model = getRuntimeHostActionModel()
@@ -222,8 +148,6 @@ describe('AddRepoLocalStartStep', () => {
"offers a server's own SSH hosts only when the server supports it (%s)",
(serverSshProjects, disabled, description) => {
const { secondaryActions } = getAddRepoLocalStartActions({
isSshLikely: false,
showRemoteAction: true,
browseHostKind: 'runtime',
serverSshProjects,
onBrowse: vi.fn(),
@@ -233,6 +157,7 @@ describe('AddRepoLocalStartStep', () => {
})
const remote = secondaryActions.find((action) => action.kind === 'remote')
expect(secondaryActions.map((action) => action.kind)).toEqual(['clone', 'remote', 'create'])
expect(remote).toMatchObject({
title: "Project on this server's SSH host",
description,
@@ -242,7 +167,7 @@ describe('AddRepoLocalStartStep', () => {
)
it('focuses Browse folder when the default Add Project step opens', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const { container, root } = await renderLocalStartStepDom()
const browseButton = findButton(container, 'Browse folder')
expect(document.activeElement).toBe(browseButton)
@@ -252,24 +177,10 @@ describe('AddRepoLocalStartStep', () => {
})
})
it('focuses Browse folder for SSH-likely users too', async () => {
const { container, root } = await renderLocalStartStepDom(true)
const browseButton = findButton(container, 'Browse folder')
const remoteButton = findButton(container, 'Project on SSH host')
expect(document.activeElement).toBe(browseButton)
expect(document.activeElement).not.toBe(remoteButton)
await act(async () => {
root.unmount()
})
})
it('renders secondary actions as enabled buttons without a disclosure toggle', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const { container, root } = await renderLocalStartStepDom()
expect(findButton(container, 'Clone from URL').disabled).toBe(false)
expect(findButton(container, 'Project on SSH host').disabled).toBe(false)
expect(findButton(container, 'Create new project').disabled).toBe(false)
await act(async () => {
@@ -278,11 +189,10 @@ describe('AddRepoLocalStartStep', () => {
})
it('disables host-scoped actions until a host is selectable', async () => {
const { container, root } = await renderLocalStartStepDom(false, { actionsDisabled: true })
const { container, root } = await renderLocalStartStepDom({ actionsDisabled: true })
expect(findButton(container, 'Browse folder').disabled).toBe(true)
expect(findButton(container, 'Clone from URL').disabled).toBe(true)
expect(findButton(container, 'Project on SSH host').disabled).toBe(true)
expect(findButton(container, 'Create new project').disabled).toBe(true)
expect(findButton(container, 'Browse folder').textContent).not.toContain('⏎')
@@ -292,7 +202,7 @@ describe('AddRepoLocalStartStep', () => {
})
it('marks the autofocused Browse action as selected with the ⏎ chip', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const { container, root } = await renderLocalStartStepDom()
expect(findButton(container, 'Browse folder').textContent).toContain('⏎')
expect(findButton(container, 'Clone from URL').textContent).not.toContain('⏎')
@@ -303,7 +213,7 @@ describe('AddRepoLocalStartStep', () => {
})
it('moves the ⏎ selection to whichever action receives focus', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const { container, root } = await renderLocalStartStepDom()
const cloneButton = findButton(container, 'Clone from URL')
await act(async () => {
@@ -319,7 +229,7 @@ describe('AddRepoLocalStartStep', () => {
})
it('clears the ⏎ selection when focus leaves the action list', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const { container, root } = await renderLocalStartStepDom()
const outsideButton = document.createElement('button')
document.body.appendChild(outsideButton)
@@ -337,7 +247,7 @@ describe('AddRepoLocalStartStep', () => {
})
it('does not show an ⏎ selection while add actions are busy', async () => {
const { container, root } = await renderLocalStartStepDom(false, {
const { container, root } = await renderLocalStartStepDom({
isAdding: true,
addProjectBusyLabel: 'Scanning repositories',
nestedScanInProgress: true,
@@ -361,7 +271,7 @@ describe('AddRepoLocalStartStep', () => {
})
it('hides the visual ⏎ chip from assistive technology', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const { container, root } = await renderLocalStartStepDom()
const browseButton = findButton(container, 'Browse folder')
const enterChip = Array.from(browseButton.querySelectorAll('[aria-hidden="true"]')).find(
(entry) => entry.textContent?.includes('⏎')
@@ -375,7 +285,7 @@ describe('AddRepoLocalStartStep', () => {
})
it('roves selection down the action list with the ArrowDown key', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const { container, root } = await renderLocalStartStepDom()
await act(async () => {
findButton(container, 'Browse folder').dispatchEvent(
@@ -61,16 +61,13 @@ function AddRepoNestedScanProgressNotice({
type AddRepoLocalStartStepProps = {
repoCount: number
isSshLikely: boolean
isAdding: boolean
addProjectBusyLabel: string | null
nestedScanInProgress: boolean
nestedScanId: string | null
hostSelector?: ReactNode
showRemoteAction?: boolean
canCreateProject?: boolean
actionsDisabled?: boolean
browseHostKind?: 'local' | 'ssh' | 'runtime'
browseHostKind?: 'local' | 'runtime'
serverSshProjects?: 'supported' | 'unsupported' | 'unknown'
onBrowse: () => void
onOpenCloneStep: () => void
@@ -81,14 +78,11 @@ type AddRepoLocalStartStepProps = {
export function AddRepoLocalStartStep({
repoCount,
isSshLikely,
isAdding,
addProjectBusyLabel,
nestedScanInProgress,
nestedScanId,
hostSelector,
showRemoteAction = true,
canCreateProject = true,
actionsDisabled = false,
browseHostKind = 'local',
serverSshProjects,
@@ -102,13 +96,10 @@ export function AddRepoLocalStartStep({
const actionsRef = useRef<HTMLDivElement | null>(null)
const actionsUnavailable = isAdding || actionsDisabled
const { primaryAction, secondaryActions } = getAddRepoLocalStartActions({
isSshLikely,
onBrowse,
onOpenCloneStep,
onOpenRemoteStep,
onOpenCreateStep,
showRemoteAction,
canCreateProject,
browseHostKind,
serverSshProjects
})
@@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({
projectHostSetups: [],
clearOrcaHookTrustForRepo: vi.fn(),
openModal: vi.fn(),
cancelNestedRepoScan: vi.fn(),
sshStateByEnvironment: new Map()
},
listServerTargets: vi.fn(),
@@ -77,10 +76,6 @@ vi.mock('@/runtime/runtime-environment-ssh-state', () => ({
connectRuntimeEnvironmentSshTarget: mocks.connectServerTarget
}))
vi.mock('../../../../shared/nested-repo-telemetry', () => ({
createNestedRepoTelemetryAttemptId: () => 'attempt-1'
}))
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
@@ -101,12 +96,12 @@ function makeRepo(overrides: Partial<Repo> = {}): Repo {
}
}
describe('useRemoteRepo default-checkout handoff', () => {
describe('useRemoteRepo', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.stateIndex = 0
mocks.stateSetters = []
mocks.stateValues = [[], 'ssh-1', '/srv/repo', null, false, null]
mocks.stateValues = [[], 'ssh-1', '/srv/repo', null, false]
mocks.storeState.repos = []
mocks.storeState.projects = []
mocks.storeState.projectHostSetups = []
@@ -129,137 +124,36 @@ describe('useRemoteRepo default-checkout handoff', () => {
})
})
it('requests an authoritative worktree refresh before handoff', async () => {
const repo = makeRepo()
mocks.addRemote.mockResolvedValue({ repo })
mocks.fetchWorktrees.mockResolvedValue(true)
it("without a paired server, never lists, connects or adds through this client's SSH", async () => {
const setStep = vi.fn()
const sshConnect = vi.fn()
Object.assign(window.api.ssh, { connect: sshConnect })
const { useRemoteRepo } = await import('./AddRepoSteps')
const result = useRemoteRepo(
mocks.fetchWorktrees,
vi.fn(),
vi.fn(),
mocks.onGitRepoReady,
vi.fn().mockResolvedValue(null)
)
const result = useRemoteRepo(mocks.fetchWorktrees, setStep, vi.fn(), mocks.onGitRepoReady, null)
await result.handleOpenRemoteStep('ssh-1')
await result.handleConnectTarget('ssh-1')
await result.handleAddRemoteRepo()
expect(mocks.addRemote).toHaveBeenCalledWith({
connectionId: 'ssh-1',
remotePath: '/srv/repo'
})
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true,
executionHostId: 'ssh:ssh-1'
})
expect(mocks.storeState.repos).toContainEqual({
...repo,
executionHostId: 'ssh:ssh-1'
})
expect(mocks.storeState.projects).toEqual(
expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })])
)
expect(mocks.storeState.projectHostSetups).toEqual(
expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })])
)
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'ssh:ssh-1')
})
it('continues to completion when refresh is not authoritative after remote add', async () => {
const repo = makeRepo()
mocks.addRemote.mockResolvedValue({ repo })
mocks.fetchWorktrees.mockResolvedValue(false)
const { useRemoteRepo } = await import('./AddRepoSteps')
const result = useRemoteRepo(
mocks.fetchWorktrees,
vi.fn(),
vi.fn(),
mocks.onGitRepoReady,
vi.fn().mockResolvedValue(null)
)
await result.handleAddRemoteRepo()
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true,
executionHostId: 'ssh:ssh-1'
})
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'ssh:ssh-1')
expect(mocks.stateSetters[3]).not.toHaveBeenCalledWith(
'Could not refresh project worktrees. Try again.'
)
})
it('preselects the preferred SSH target when opening Browse for a selected host', async () => {
mocks.stateValues = [[], null, '~/', null, false, null]
const { useRemoteRepo } = await import('./AddRepoSteps')
const result = useRemoteRepo(
mocks.fetchWorktrees,
vi.fn(),
vi.fn(),
mocks.onGitRepoReady,
vi.fn().mockResolvedValue(null)
)
await result.handleOpenRemoteStep('ssh-2')
expect(mocks.listTargets).toHaveBeenCalled()
expect(mocks.getState).toHaveBeenCalledWith({ targetId: 'ssh-1' })
expect(mocks.getState).toHaveBeenCalledWith({ targetId: 'ssh-2' })
expect(mocks.stateSetters[1]).toHaveBeenCalledWith('ssh-2')
})
it('pins SSH nested scans and cancellation to the local provider', async () => {
const scanNestedRepos = vi.fn().mockResolvedValue(null)
mocks.addRemote.mockResolvedValue({ repo: makeRepo() })
mocks.fetchWorktrees.mockResolvedValue(true)
const { useRemoteRepo } = await import('./AddRepoSteps')
const result = useRemoteRepo(
mocks.fetchWorktrees,
vi.fn(),
vi.fn(),
mocks.onGitRepoReady,
scanNestedRepos
)
await result.handleAddRemoteRepo()
expect(scanNestedRepos).toHaveBeenCalledWith(
'/srv/repo',
'ssh-1',
expect.objectContaining({ runtimeEnvironmentId: null })
)
mocks.stateIndex = 0
mocks.stateValues = [[], 'ssh-1', '/srv/repo', null, false, 'scan-ssh']
const active = useRemoteRepo(mocks.fetchWorktrees, vi.fn(), vi.fn())
active.stopRemoteNestedScan()
active.resetRemoteState()
expect(mocks.storeState.cancelNestedRepoScan).toHaveBeenCalledWith('scan-ssh', {
runtimeEnvironmentId: null
})
expect(setStep).not.toHaveBeenCalled()
expect(mocks.listTargets).not.toHaveBeenCalled()
expect(sshConnect).not.toHaveBeenCalled()
expect(mocks.connectServerTarget).not.toHaveBeenCalled()
expect(mocks.addRemote).not.toHaveBeenCalled()
expect(mocks.addServerRepo).not.toHaveBeenCalled()
expect(mocks.fetchWorktrees).not.toHaveBeenCalled()
})
// Adapted from the community PR #8492 by @jae-heo.
describe("a paired server's own SSH hosts (#8489)", () => {
function serverRemoteRepo(scanNestedRepos = vi.fn().mockResolvedValue(null)) {
function serverRemoteRepo() {
return import('./AddRepoSteps').then(({ useRemoteRepo }) =>
useRemoteRepo(
mocks.fetchWorktrees,
vi.fn(),
vi.fn(),
mocks.onGitRepoReady,
scanNestedRepos,
undefined,
undefined,
'env-linux'
)
useRemoteRepo(mocks.fetchWorktrees, vi.fn(), vi.fn(), mocks.onGitRepoReady, 'env-linux')
)
}
it("lists the server's hosts, never this client's", async () => {
mocks.stateValues = [[], null, '~/', null, false, null]
mocks.stateValues = [[], null, '~/', null, false]
mocks.listServerTargets.mockResolvedValue([
{
id: 'ssh-p8',
@@ -287,7 +181,7 @@ describe('useRemoteRepo default-checkout handoff', () => {
})
it('shows why when the server cannot list them, with no local fallback', async () => {
mocks.stateValues = [[], null, '~/', null, false, null]
mocks.stateValues = [[], null, '~/', null, false]
mocks.listServerTargets.mockRejectedValue(
new Error('Update this Orca server to manage its SSH hosts from here.')
)
@@ -303,12 +197,11 @@ describe('useRemoteRepo default-checkout handoff', () => {
it('adds the project through the server and refreshes it as server-owned', async () => {
const repo = makeRepo({ connectionId: 'ssh-p8', executionHostId: 'ssh:ssh-p8' })
mocks.stateValues = [[], 'ssh-p8', '/srv/repo', null, false, null]
mocks.stateValues = [[], 'ssh-p8', '/srv/repo', null, false]
mocks.addServerRepo.mockResolvedValue(repo)
mocks.fetchWorktrees.mockResolvedValue(true)
const scanNestedRepos = vi.fn()
const result = await serverRemoteRepo(scanNestedRepos)
const result = await serverRemoteRepo()
await result.handleAddRemoteRepo()
expect(mocks.addServerRepo).toHaveBeenCalledWith('env-linux', {
@@ -316,7 +209,6 @@ describe('useRemoteRepo default-checkout handoff', () => {
remotePath: '/srv/repo'
})
expect(mocks.addRemote).not.toHaveBeenCalled()
expect(scanNestedRepos).not.toHaveBeenCalled()
expect(mocks.storeState.repos).toContainEqual({
...repo,
executionHostId: 'runtime:env-linux'
@@ -329,7 +221,7 @@ describe('useRemoteRepo default-checkout handoff', () => {
})
it('hands a non-git folder to the confirm dialog bound to the server', async () => {
mocks.stateValues = [[], 'ssh-p8', '/srv/notes', null, false, null]
mocks.stateValues = [[], 'ssh-p8', '/srv/notes', null, false]
mocks.addServerRepo.mockRejectedValue(new Error('Not a valid git repository: /srv/notes'))
const result = await serverRemoteRepo()
@@ -343,7 +235,7 @@ describe('useRemoteRepo default-checkout handoff', () => {
})
it('connects the host through the server', async () => {
mocks.stateValues = [[], null, '~/', null, false, null]
mocks.stateValues = [[], null, '~/', null, false]
mocks.connectServerTarget.mockResolvedValue({
targetId: 'ssh-p8',
status: 'connected',
@@ -1,9 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import type { NestedRepoScanResult } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
import { addRuntimeSshRepo } from '@/runtime/runtime-ssh-target-management'
import { connectRuntimeEnvironmentSshTarget } from '@/runtime/runtime-environment-ssh-state'
import {
@@ -11,70 +9,35 @@ import {
withServerSshStates,
type RemoteRepoTarget
} from './add-repo-server-ssh-targets'
import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-repo-telemetry'
import { translate } from '@/i18n/i18n'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert'
import { worktreeRefreshOptions } from './add-repo-runtime-owner'
import type { ExecutionHostId } from '../../../../shared/execution-host'
// ── SSH host project hook ───────────────────────────────────────────
async function loadLocalSshTargets(): Promise<RemoteRepoTarget[]> {
const targets = await window.api.ssh.listTargets()
return Promise.all(
targets.map(async (t) => {
const state = await window.api.ssh.getState({ targetId: t.id })
return { ...t, state: state ?? undefined }
})
)
}
function unwrapAddedRepo(result: { repo: Repo } | { error: string }): Repo {
if ('error' in result) {
throw new Error(result.error)
}
return result.repo
}
// ── A paired server's SSH host project hook ─────────────────────────
export function useRemoteRepo(
fetchWorktrees: (
repoId: string,
options?: { requireAuthoritative?: boolean; executionHostId?: ExecutionHostId }
options?: {
requireAuthoritative?: boolean
executionHostId?: ExecutionHostId
}
) => Promise<unknown>,
setStep: (step: 'add' | 'clone' | 'remote' | 'create' | 'nested') => void,
closeModal: () => void,
onGitRepoReady?: (repoId: string, executionHostId?: ExecutionHostId) => void | Promise<void>,
scanNestedRepos?: (
path: string,
connectionId: string | undefined,
controls: {
scanId?: string
onProgress?: (scan: NestedRepoScanResult) => void
runtimeEnvironmentId: string | null
}
) => Promise<NestedRepoScanResult | null>,
showNestedRepoReview?: (
scan: NestedRepoScanResult,
selectedPath: string,
connectionId: string,
attemptId: string,
inProgress: boolean,
scanId: string | null
) => void,
onNestedScanResult?: (scan: NestedRepoScanResult | null, attemptId: string) => void,
onGitRepoReady: (repoId: string, executionHostId?: ExecutionHostId) => void | Promise<void>,
/** A paired server: its own SSH hosts are listed, browsed and added by that server only. */
serverEnvironmentId: string | null = null
serverEnvironmentId: string | null
) {
const [sshTargets, setSshTargets] = useState<RemoteRepoTarget[]>([])
const [selectedTargetId, setSelectedTargetId] = useState<string | null>(null)
const [remotePath, setRemotePath] = useState('~/')
const [remoteError, setRemoteError] = useState<string | null>(null)
const [isAddingRemote, setIsAddingRemote] = useState(false)
const [remoteNestedScanId, setRemoteNestedScanId] = useState<string | null>(null)
const remoteGenRef = useRef(0)
const mountedRef = useMountedRef()
const cancelNestedRepoScan = useAppStore((s) => s.cancelNestedRepoScan)
const resetRemoteState = useCallback(() => {
remoteGenRef.current++
@@ -83,27 +46,17 @@ export function useRemoteRepo(
setRemotePath('~/')
setRemoteError(null)
setIsAddingRemote(false)
if (remoteNestedScanId) {
void cancelNestedRepoScan(remoteNestedScanId, { runtimeEnvironmentId: null })
}
setRemoteNestedScanId(null)
}, [cancelNestedRepoScan, remoteNestedScanId])
const stopRemoteNestedScan = useCallback(() => {
if (!remoteNestedScanId) {
return
}
void cancelNestedRepoScan(remoteNestedScanId, { runtimeEnvironmentId: null })
}, [cancelNestedRepoScan, remoteNestedScanId])
}, [])
const handleOpenRemoteStep = useCallback(
async (preferredTargetId?: string | null) => {
if (!serverEnvironmentId) {
return
}
const gen = ++remoteGenRef.current
setStep('remote')
try {
const withState = serverEnvironmentId
? await loadServerSshTargets(serverEnvironmentId)
: await loadLocalSshTargets()
const withState = await loadServerSshTargets(serverEnvironmentId)
if (gen !== remoteGenRef.current) {
return
}
@@ -124,17 +77,12 @@ export function useRemoteRepo(
return
}
setSshTargets([])
if (serverEnvironmentId) {
setRemoteError(extractIpcErrorMessage(error, String(error)))
}
setRemoteError(extractIpcErrorMessage(error, String(error)))
}
},
[serverEnvironmentId, setStep]
)
// Why: keep the target list's connection state in sync while the dialog is
// open, so clicking the inline Connect button below updates the dot/label
// live without the user reopening the step.
const serverSshStates = useAppStore((s) =>
serverEnvironmentId
? s.sshStateByEnvironment.get(serverEnvironmentId)?.connectionStates
@@ -146,33 +94,17 @@ export function useRemoteRepo(
[serverSshStates, sshTargets]
)
useEffect(() => {
if (serverEnvironmentId) {
return
}
const unsubscribe = window.api.ssh.onStateChanged(({ targetId, state }) => {
setSshTargets((prev) => prev.map((t) => (t.id === targetId ? { ...t, state } : t)))
if (state.status === 'connected') {
setSelectedTargetId((curr) => curr ?? targetId)
}
})
return unsubscribe
}, [serverEnvironmentId])
const handleConnectTarget = useCallback(
async (targetId: string) => {
if (!serverEnvironmentId) {
return
}
try {
if (serverEnvironmentId) {
const state = await connectRuntimeEnvironmentSshTarget(serverEnvironmentId, targetId)
if (state) {
setSshTargets((prev) => prev.map((t) => (t.id === targetId ? { ...t, state } : t)))
setSelectedTargetId((curr) =>
state.status === 'connected' ? (curr ?? targetId) : curr
)
}
return
const state = await connectRuntimeEnvironmentSshTarget(serverEnvironmentId, targetId)
if (state) {
setSshTargets((prev) => prev.map((t) => (t.id === targetId ? { ...t, state } : t)))
setSelectedTargetId((curr) => (state.status === 'connected' ? (curr ?? targetId) : curr))
}
await window.api.ssh.connect({ targetId })
} catch (err) {
toast.error(
err instanceof Error
@@ -185,7 +117,7 @@ export function useRemoteRepo(
)
const handleAddRemoteRepo = useCallback(async () => {
if (!selectedTargetId || !remotePath.trim()) {
if (!serverEnvironmentId || !selectedTargetId || !remotePath.trim()) {
return
}
@@ -194,54 +126,13 @@ export function useRemoteRepo(
setIsAddingRemote(true)
setRemoteError(null)
try {
const attemptId = createNestedRepoTelemetryAttemptId()
const scanId = `nested-repo-scan-${Date.now()}-${Math.random().toString(36).slice(2)}`
setRemoteNestedScanId(scanId)
// Why no nested scan on a server's host: that server's scan cannot reach its SSH targets.
const scan = serverEnvironmentId
? null
: await scanNestedRepos?.(trimmedRemotePath, selectedTargetId, {
scanId,
runtimeEnvironmentId: null,
onProgress: (progressScan) => {
if (
gen !== remoteGenRef.current ||
!mountedRef.current ||
progressScan.selectedPathKind !== 'non_git_folder' ||
progressScan.repos.length === 0
) {
return
}
showNestedRepoReview?.(
progressScan,
trimmedRemotePath,
selectedTargetId,
attemptId,
true,
scanId
)
}
})
if (!mountedRef.current || gen !== remoteGenRef.current) {
return
}
onNestedScanResult?.(scan ?? null, attemptId)
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
showNestedRepoReview?.(scan, trimmedRemotePath, selectedTargetId, attemptId, false, scanId)
setRemoteNestedScanId(null)
return
}
setRemoteNestedScanId(null)
const request = { connectionId: selectedTargetId, remotePath: trimmedRemotePath }
const addedRepo = serverEnvironmentId
? await addRuntimeSshRepo(serverEnvironmentId, request)
: unwrapAddedRepo(await window.api.repos.addRemote(request))
const { alreadyPresent, repo } = upsertAddedRepoWithProjectHostSetup(
addedRepo,
serverEnvironmentId
? { runtimeEnvironmentId: serverEnvironmentId }
: { sshConnectionId: selectedTargetId }
)
const addedRepo = await addRuntimeSshRepo(serverEnvironmentId, {
connectionId: selectedTargetId,
remotePath: trimmedRemotePath
})
const { alreadyPresent, repo } = upsertAddedRepoWithProjectHostSetup(addedRepo, {
runtimeEnvironmentId: serverEnvironmentId
})
if (alreadyPresent) {
useAppStore.getState().clearOrcaHookTrustForRepo(repo.id)
@@ -256,14 +147,12 @@ export function useRemoteRepo(
)
// Why: the repo is already persisted here; if SSH refresh is temporarily
// non-authoritative, finish onto the project row instead of stranding the dialog.
const ownerOptions = serverEnvironmentId
? worktreeRefreshOptions(serverEnvironmentId)
: worktreeRefreshOptions(undefined, selectedTargetId)
const ownerOptions = worktreeRefreshOptions(serverEnvironmentId)
await fetchWorktrees(repo.id, ownerOptions)
if (!mountedRef.current || gen !== remoteGenRef.current) {
return
}
await onGitRepoReady?.(repo.id, ownerOptions.executionHostId)
await onGitRepoReady(repo.id, ownerOptions.executionHostId)
} catch (err) {
const message = extractIpcErrorMessage(err, String(err))
if (message.includes('Not a valid git repository')) {
@@ -274,7 +163,7 @@ export function useRemoteRepo(
useAppStore.getState().openModal('confirm-non-git-folder', {
folderPath: trimmedRemotePath,
connectionId: selectedTargetId,
...(serverEnvironmentId ? { runtimeEnvironmentId: serverEnvironmentId } : {})
runtimeEnvironmentId: serverEnvironmentId
})
return
}
@@ -284,16 +173,12 @@ export function useRemoteRepo(
} finally {
if (mountedRef.current && gen === remoteGenRef.current) {
setIsAddingRemote(false)
setRemoteNestedScanId(null)
}
}
}, [
serverEnvironmentId,
selectedTargetId,
remotePath,
scanNestedRepos,
showNestedRepoReview,
onNestedScanResult,
fetchWorktrees,
mountedRef,
closeModal,
@@ -306,14 +191,12 @@ export function useRemoteRepo(
remotePath,
remoteError,
isAddingRemote,
isScanningNested: Boolean(remoteNestedScanId),
setSelectedTargetId,
setRemotePath,
setRemoteError,
resetRemoteState,
handleOpenRemoteStep,
handleAddRemoteRepo,
handleConnectTarget,
stopRemoteNestedScan
handleConnectTarget
}
}
@@ -67,7 +67,6 @@ type CreateProjectLocationFieldProps = {
isCreating: boolean
manualParentEntry: boolean
runtimeEnvironmentId?: string | null
sshTargetId?: string | null
onParentChange: (value: string) => void
onPickParent: () => void
onBrowseServer: () => void
@@ -78,7 +77,6 @@ export function CreateProjectLocationField({
isCreating,
manualParentEntry,
runtimeEnvironmentId,
sshTargetId,
onParentChange,
onPickParent,
onBrowseServer
@@ -110,7 +108,7 @@ export function CreateProjectLocationField({
size="icon"
className="h-11 w-11 shrink-0"
onClick={onBrowseServer}
disabled={isCreating || (!runtimeEnvironmentId && !sshTargetId)}
disabled={isCreating || !runtimeEnvironmentId}
aria-label={translate(
'auto.components.sidebar.CreateProjectLocationField.f520f83a97',
'Browse host filesystem'
@@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({
settings: {}
},
addRemote: vi.fn(),
addRuntimeSshRepo: vi.fn(),
onboardingGet: vi.fn(),
activateAndRevealWorktree: vi.fn()
}))
@@ -84,10 +85,15 @@ vi.mock('sonner', () => ({
}
}))
vi.mock('@/runtime/runtime-ssh-target-management', () => ({
addRuntimeSshRepo: mocks.addRuntimeSshRepo
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: mocks.activateAndRevealWorktree
}))
import { toast } from 'sonner'
import NonGitFolderDialog from './NonGitFolderDialog'
function makeWorktree(id: string, path: string, hostId: Worktree['hostId']): Worktree {
@@ -173,65 +179,71 @@ describe('NonGitFolderDialog', () => {
})
})
it('names the SSH folder project after the requested display name', async () => {
it("adds a folder on a server's SSH host through that server, named as requested", async () => {
mocks.state.modalData = {
folderPath: '/srv/non-git',
connectionId: 'ssh-1',
runtimeEnvironmentId: 'env-1',
displayName: 'inf-오케스트레이터'
}
mocks.addRemote.mockResolvedValue({
repo: {
id: 'ssh-repo',
path: '/srv/non-git',
displayName: 'inf-오케스트레이터',
badgeColor: '#111',
addedAt: 1,
kind: 'folder',
connectionId: 'ssh-1'
} satisfies Repo
})
mocks.addRuntimeSshRepo.mockResolvedValue({
id: 'ssh-repo',
path: '/srv/non-git',
displayName: 'inf-오케스트레이터',
badgeColor: '#111',
addedAt: 1,
kind: 'folder'
} satisfies Repo)
renderToStaticMarkup(<NonGitFolderDialog />)
const button = mocks.buttons.find((entry) => entry.label.includes('Open as Folder'))
button?.onClick?.()
await vi.waitFor(() =>
expect(mocks.addRemote).toHaveBeenCalledWith({
connectionId: 'ssh-1',
remotePath: '/srv/non-git',
kind: 'folder',
displayName: 'inf-오케스트레이터'
expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('ssh-repo', {
requireAuthoritative: true,
executionHostId: 'runtime:env-1'
})
)
expect(mocks.addRuntimeSshRepo).toHaveBeenCalledWith('env-1', {
connectionId: 'ssh-1',
remotePath: '/srv/non-git',
kind: 'folder',
displayName: 'inf-오케스트레이터'
})
expect(mocks.state.repos).toContainEqual(
expect.objectContaining({ id: 'ssh-repo', executionHostId: 'runtime:env-1' })
)
expect(mocks.addRemote).not.toHaveBeenCalled()
expect(mocks.state.addNonGitFolder).not.toHaveBeenCalled()
})
it('activates only the selected SSH folder when repo IDs collide', async () => {
it("activates only the server's SSH folder when repo IDs collide", async () => {
const repo: Repo = {
id: 'shared-repo',
path: '/srv/non-git',
displayName: 'SSH folder',
badgeColor: '#111',
addedAt: 1,
kind: 'folder',
connectionId: 'ssh-1'
kind: 'folder'
}
const localWorktree = makeWorktree('shared-repo::/local/non-git', '/local/non-git', 'local')
const sshWorktree = makeWorktree('shared-repo::/srv/non-git', '/srv/non-git', 'ssh:ssh-1')
const serverWorktree = makeWorktree(
'shared-repo::/srv/non-git',
'/srv/non-git',
'runtime:env-1'
)
mocks.state.modalData = {
folderPath: '/srv/non-git',
connectionId: 'ssh-1'
connectionId: 'ssh-1',
runtimeEnvironmentId: 'env-1'
}
mocks.state.repos = [
{
...repo,
path: '/local/non-git',
connectionId: null,
executionHostId: 'local'
}
{ ...repo, path: '/local/non-git', connectionId: null, executionHostId: 'local' }
]
mocks.addRemote.mockResolvedValue({ repo })
mocks.addRuntimeSshRepo.mockResolvedValue(repo)
mocks.state.fetchWorktrees.mockImplementation(async () => {
mocks.state.worktreesByRepo = { [repo.id]: [localWorktree, sshWorktree] }
mocks.state.worktreesByRepo = { [repo.id]: [localWorktree, serverWorktree] }
return true
})
renderToStaticMarkup(<NonGitFolderDialog />)
@@ -240,18 +252,30 @@ describe('NonGitFolderDialog', () => {
button?.onClick?.()
await vi.waitFor(() =>
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(sshWorktree.id, {
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(serverWorktree.id, {
sidebarRevealBehavior: 'auto',
executionHostId: 'ssh:ssh-1'
executionHostId: 'runtime:env-1'
})
)
expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true,
executionHostId: 'ssh:ssh-1'
})
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalledWith(
localWorktree.id,
expect.anything()
)
})
it('refuses an SSH folder with no server to add it, never adding it on this computer', async () => {
mocks.state.modalData = { folderPath: '/srv/non-git', connectionId: 'ssh-1' }
renderToStaticMarkup(<NonGitFolderDialog />)
const button = mocks.buttons.find((entry) => entry.label.includes('Open as Folder'))
button?.onClick?.()
await vi.waitFor(() =>
expect(toast.error).toHaveBeenCalledWith('SSH connection "ssh-1" not found or not connected')
)
expect(mocks.addRemote).not.toHaveBeenCalled()
expect(mocks.addRuntimeSshRepo).not.toHaveBeenCalled()
expect(mocks.state.addNonGitFolder).not.toHaveBeenCalled()
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
})
@@ -21,36 +21,30 @@ import { worktreeRefreshOptions } from './add-repo-runtime-owner'
import { addRuntimeSshRepo } from '@/runtime/runtime-ssh-target-management'
import type { Repo } from '../../../../shared/repo-types'
// Why: with a server set, the SSH target is that server's own host and only the server adds it.
// Why: the SSH target is a paired server's own host, and only that server adds a project on it.
async function addSshFolderProject(args: {
connectionId: string
runtimeEnvironmentId: string | null
remotePath: string
displayName: string
}): Promise<{ repo: Repo; ownerOptions: ReturnType<typeof worktreeRefreshOptions> }> {
const request = {
}): Promise<{
repo: Repo
ownerOptions: ReturnType<typeof worktreeRefreshOptions>
}> {
if (!args.runtimeEnvironmentId) {
throw new Error(`SSH connection "${args.connectionId}" not found or not connected`)
}
const added = await addRuntimeSshRepo(args.runtimeEnvironmentId, {
connectionId: args.connectionId,
remotePath: args.remotePath,
kind: 'folder' as const,
kind: 'folder',
...(args.displayName ? { displayName: args.displayName } : {})
}
if (args.runtimeEnvironmentId) {
const added = await addRuntimeSshRepo(args.runtimeEnvironmentId, request)
return {
repo: upsertAddedRepoWithProjectHostSetup(added, {
runtimeEnvironmentId: args.runtimeEnvironmentId
}).repo,
ownerOptions: worktreeRefreshOptions(args.runtimeEnvironmentId)
}
}
const result = await window.api.repos.addRemote(request)
if ('error' in result) {
throw new Error(result.error)
}
})
return {
repo: upsertAddedRepoWithProjectHostSetup(result.repo, { sshConnectionId: args.connectionId })
.repo,
ownerOptions: worktreeRefreshOptions(undefined, args.connectionId)
repo: upsertAddedRepoWithProjectHostSetup(added, {
runtimeEnvironmentId: args.runtimeEnvironmentId
}).repo,
ownerOptions: worktreeRefreshOptions(args.runtimeEnvironmentId)
}
}
@@ -128,7 +122,7 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() {
})
}
} catch (err) {
// This code path calls addRemote directly (not through the store),
// This code path calls the server directly (not through the store),
// so the store's toast handling does not apply.
toast.error(
err instanceof Error
@@ -9,8 +9,7 @@ describe('routeAddRepoBrowse', () => {
routeAddRepoBrowse(parseExecutionHostId('runtime:paired-host'), {
browseLocal: pickFolders,
browseRuntime,
browseSsh: vi.fn()
browseRuntime
})
expect(browseRuntime).toHaveBeenCalledOnce()
@@ -22,22 +21,18 @@ describe('routeAddRepoBrowse', () => {
routeAddRepoBrowse(parseExecutionHostId('local'), {
browseLocal: pickFolders,
browseRuntime: vi.fn(),
browseSsh: vi.fn()
browseRuntime: vi.fn()
})
expect(pickFolders).toHaveBeenCalledOnce()
})
it('preserves SSH host browsing', () => {
const browseSsh = vi.fn()
it('opens nothing for an SSH host, which holds projects only through its server', () => {
const actions = { browseLocal: vi.fn(), browseRuntime: vi.fn() }
routeAddRepoBrowse(parseExecutionHostId('ssh:builder'), {
browseLocal: vi.fn(),
browseRuntime: vi.fn(),
browseSsh
})
routeAddRepoBrowse(parseExecutionHostId('ssh:builder'), actions)
expect(browseSsh).toHaveBeenCalledWith('builder')
expect(actions.browseLocal).not.toHaveBeenCalled()
expect(actions.browseRuntime).not.toHaveBeenCalled()
})
})
@@ -3,7 +3,6 @@ import type { ParsedExecutionHost } from '../../../../shared/execution-host'
export type AddRepoBrowseAuthorityActions = {
browseLocal: () => void
browseRuntime: () => void
browseSsh: (targetId: string) => void
}
export function routeAddRepoBrowse(
@@ -14,10 +13,6 @@ export function routeAddRepoBrowse(
actions.browseRuntime()
return
}
if (host?.kind === 'ssh') {
actions.browseSsh(host.targetId)
return
}
if (host?.kind === 'local') {
actions.browseLocal()
}
@@ -1,7 +1,8 @@
import type { SidebarHostOption } from './sidebar-host-options'
// Why not SSH rows: a project lives on the host's managed server row; an SSH row is only connected.
export function canSelectAddRepoHost(host: Pick<SidebarHostOption, 'health' | 'kind'>): boolean {
return host.health === 'local' || host.health === 'available'
return host.kind !== 'ssh' && (host.health === 'local' || host.health === 'available')
}
export function canConnectAddRepoHost(host: Pick<SidebarHostOption, 'health' | 'kind'>): boolean {
@@ -7,9 +7,7 @@ export type AddRepoLocalStartActionHandlers = {
onOpenCloneStep: () => void
onOpenRemoteStep: () => void
onOpenCreateStep: () => void
showRemoteAction?: boolean
canCreateProject?: boolean
browseHostKind?: 'local' | 'ssh' | 'runtime'
browseHostKind?: 'local' | 'runtime'
/** A paired server's own SSH hosts; `unsupported` when it predates managing them. */
serverSshProjects?: 'supported' | 'unsupported' | 'unknown'
}
@@ -24,47 +22,33 @@ export type AddRepoLocalStartAction = {
}
export function getAddRepoLocalStartActions({
isSshLikely,
onBrowse,
onOpenCloneStep,
onOpenRemoteStep,
onOpenCreateStep,
showRemoteAction = true,
canCreateProject = true,
browseHostKind = 'local',
serverSshProjects
}: { isSshLikely: boolean } & AddRepoLocalStartActionHandlers): {
}: AddRepoLocalStartActionHandlers): {
primaryAction: AddRepoLocalStartAction
secondaryActions: AddRepoLocalStartAction[]
} {
const primaryAction = {
kind: 'browse' as const,
icon: FolderOpen,
title:
browseHostKind === 'ssh'
title: translate(
'auto.components.sidebar.add.repo.local.start.actions.2281fdc8c7',
'Browse folder'
),
description:
browseHostKind === 'runtime'
? translate(
'auto.components.sidebar.add.repo.local.start.actions.sshBrowseTitle',
'Open project on SSH host'
'auto.components.sidebar.add.repo.local.start.actions.runtimeBrowseDescription',
'Existing Git repository or folder on this host'
)
: translate(
'auto.components.sidebar.add.repo.local.start.actions.2281fdc8c7',
'Browse folder'
'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
'Local project, Git repo, or folder with many repos'
),
description:
browseHostKind === 'ssh'
? translate(
'auto.components.sidebar.add.repo.local.start.actions.sshBrowseDescription',
'Existing Git repository or folder on this SSH host'
)
: browseHostKind === 'runtime'
? translate(
'auto.components.sidebar.add.repo.local.start.actions.runtimeBrowseDescription',
'Existing Git repository or folder on this host'
)
: translate(
'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
'Local project, Git repo, or folder with many repos'
),
onClick: onBrowse
}
@@ -90,19 +74,7 @@ export function getAddRepoLocalStartActions({
disabled: serverSshProjects !== 'supported',
onClick: onOpenRemoteStep
}
: {
kind: 'remote' as const,
icon: Monitor,
title: translate(
'auto.components.sidebar.add.repo.local.start.actions.3d162cc76f',
'Project on SSH host'
),
description: translate(
'auto.components.sidebar.add.repo.local.start.actions.a6c20dca96',
'Open a project folder from an SSH host'
),
onClick: onOpenRemoteStep
}
: null
const clone = {
kind: 'clone' as const,
icon: Globe,
@@ -123,24 +95,14 @@ export function getAddRepoLocalStartActions({
'auto.components.sidebar.add.repo.local.start.actions.c709860596',
'Create new project'
),
description: canCreateProject
? translate(
'auto.components.sidebar.add.repo.local.start.actions.d72789705e',
'Start from an empty folder'
)
: translate(
'auto.components.sidebar.add.repo.local.start.actions.sshCreateUnavailable',
'Not available for SSH hosts yet'
),
disabled: !canCreateProject,
description: translate(
'auto.components.sidebar.add.repo.local.start.actions.d72789705e',
'Start from an empty folder'
),
onClick: onOpenCreateStep
}
const secondaryActions = showRemoteAction
? isSshLikely
? [remote, clone, create]
: [clone, remote, create]
: [clone, create]
const secondaryActions = remote ? [clone, remote, create] : [clone, create]
return { primaryAction, secondaryActions }
}
@@ -1,8 +1,7 @@
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId,
toSshExecutionHostId
toRuntimeExecutionHostId
} from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
import { useAppStore } from '@/store'
@@ -10,14 +9,9 @@ import { getRepoHostIdentity } from '@/store/slices/repo-host-identity'
type AddedRepoOwner = {
runtimeEnvironmentId?: string | null
sshConnectionId?: string | null
}
function repoWithCapturedOwner(repo: Repo, owner: AddedRepoOwner): Repo {
const sshConnectionId = owner.sshConnectionId?.trim()
if (sshConnectionId) {
return { ...repo, executionHostId: toSshExecutionHostId(sshConnectionId) }
}
if (owner.runtimeEnvironmentId !== undefined) {
const runtimeEnvironmentId = owner.runtimeEnvironmentId?.trim()
return {
@@ -104,17 +104,4 @@ describe('getCloneDestinationAutoFill', () => {
})
).toBeNull()
})
it('does not fill SSH clone destinations from the local workspace directory', () => {
expect(
getCloneDestinationAutoFill({
step: 'clone',
cloneDestination: '',
activeRuntimeEnvironmentId: null,
sshTargetId: 'openclaw-2',
workspaceDir: '/Users/mvanhorn/orca/workspaces',
cloneStepAutoFilled: false
})
).toBeNull()
})
})
@@ -30,21 +30,19 @@ export function getCloneDestinationAutoFill({
step,
cloneDestination,
activeRuntimeEnvironmentId,
sshTargetId,
workspaceDir,
cloneStepAutoFilled
}: {
step: string
cloneDestination: string
activeRuntimeEnvironmentId: string | null | undefined
sshTargetId?: string | null | undefined
workspaceDir: string | null | undefined
cloneStepAutoFilled: boolean
}): { destination: string } | null {
if (step !== 'clone' || cloneStepAutoFilled || cloneDestination) {
return null
}
if (activeRuntimeEnvironmentId?.trim() || sshTargetId?.trim() || !workspaceDir) {
if (activeRuntimeEnvironmentId?.trim() || !workspaceDir) {
return null
}
return { destination: getDefaultCloneParent(workspaceDir) }
@@ -119,15 +119,16 @@ describe('useAddRepoHostSelection', () => {
})
})
it('exposes the selected SSH target id', async () => {
it('never selects a connected SSH row; it holds the actions until the server row stands for it', async () => {
// Why: before the catalog hydrates, a connected SSH host is still its own row.
mocks.stateValues = ['ssh:ssh-1', false]
const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection')
const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() })
expect(result.selectedHostId).toBe('ssh:ssh-1')
expect(result.selectedParsedHost).toMatchObject({ kind: 'ssh', targetId: 'ssh-1' })
expect(result.selectedSshTargetId).toBe('ssh-1')
expect(result.selectedHostId).toBeNull()
expect(result.selectedParsedHost).toBeNull()
expect(result.displayedHostId).toBe('ssh:ssh-1')
})
it('selects a runtime host without changing the durable active server', async () => {
@@ -193,7 +194,7 @@ describe('useAddRepoHostSelection', () => {
}
)
it('selects a local or SSH host without changing the durable active server', async () => {
it('selects local without changing the durable active server, but not an SSH row', async () => {
mocks.stateValues = ['runtime:env-1', false]
mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' }
const setStep = vi.fn()
@@ -201,13 +202,16 @@ describe('useAddRepoHostSelection', () => {
const result = useAddRepoHostSelection({ isOpen: true, setStep })
await result.handleSelectAddProjectHost('ssh:ssh-1')
expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('ssh:ssh-1')
expect(setStep).not.toHaveBeenCalled()
await result.handleSelectAddProjectHost('local')
expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled()
expect(mocks.stateSetters[0]).toHaveBeenCalledWith('ssh:ssh-1')
expect(mocks.stateSetters[0]).toHaveBeenCalledWith('local')
expect(setStep).toHaveBeenCalledWith('add')
})
it('falls back from a disconnected selected SSH host to Local Mac', async () => {
it('keeps a disconnected SSH host it was asked for instead of falling back to Local Mac', async () => {
mocks.stateValues = ['ssh:ssh-1', false]
mocks.hostOptions[1] = {
...mocks.hostOptions[1],
@@ -217,8 +221,8 @@ describe('useAddRepoHostSelection', () => {
const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() })
expect(result.selectedHostId).toBe('local')
expect(result.selectedSshTargetId).toBeNull()
expect(result.selectedHostId).toBeNull()
expect(result.displayedHostId).toBe('ssh:ssh-1')
})
it('does not select a disconnected SSH host', async () => {
@@ -31,7 +31,6 @@ export function useAddRepoHostSelection({
/** The host the picker shows: the chosen one, even while it is still coming up. */
displayedHostId: ExecutionHostId | null
selectedParsedHost: ReturnType<typeof parseExecutionHostId>
selectedSshTargetId: string | null
hostSelectorOpen: boolean
setHostSelectorOpen: (open: boolean) => void
handleSelectAddProjectHost: (hostId: ExecutionHostId) => Promise<void>
@@ -76,12 +75,12 @@ export function useAddRepoHostSelection({
const requestedHost =
resolvedHost && selectableHostOptions.includes(resolvedHost) ? resolvedHost : undefined
const requestedHostSelectable = requestedHost ? canSelectAddRepoHost(requestedHost) : false
// Why: a merged SSH host is still coming up on its server right after its connect; that blocks
// the actions rather than silently turning into this computer.
// Why: right after a connect the SSH host is still coming up on its server (or not yet merged
// into its row); that blocks the actions rather than silently turning into this computer.
const requestedHostPending =
requestedHost !== undefined &&
!requestedHostSelectable &&
(requestedHost.aliasHostIds?.length ?? 0) > 0
(requestedHost.kind === 'ssh' || (requestedHost.aliasHostIds?.length ?? 0) > 0)
const selectedHost = requestedHostSelectable
? requestedHost
: requestedHostPending
@@ -95,8 +94,6 @@ export function useAddRepoHostSelection({
selectedHost?.id ?? (isWebClient || requestedHostPending ? null : LOCAL_EXECUTION_HOST_ID)
const displayedHostId = requestedHostPending ? (requestedHost?.id ?? null) : selectedHostId
const selectedParsedHost = parseExecutionHostId(selectedHostId)
const selectedSshTargetId =
selectedParsedHost?.kind === 'ssh' ? selectedParsedHost.targetId : null
useEffect(() => {
if (isOpen && !previousOpenRef.current) {
@@ -202,7 +199,6 @@ export function useAddRepoHostSelection({
selectedHostId,
displayedHostId,
selectedParsedHost,
selectedSshTargetId,
hostSelectorOpen,
setHostSelectorOpen,
handleSelectAddProjectHost,
@@ -12,9 +12,7 @@ vi.mock('react', async (importOriginal) => {
const mocks = vi.hoisted(() => ({
state: {
closeModal: vi.fn(),
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
closeModal: vi.fn()
},
markOnboardingProjectAdded: vi.fn(() => Promise.resolve())
}))
@@ -92,22 +90,4 @@ describe('useAddRepoHostedController', () => {
// dialog's close animation while the new project lands in the picker.
expect(order).toEqual(['close', 'added'])
})
it('SSH settings navigation closes both hosted dialog and composer modal', () => {
const onOpenChange = vi.fn()
const { handleOpenSshSettings } = useAddRepoHostedController({
open: true,
onOpenChange,
onProjectAdded: vi.fn()
})
handleOpenSshSettings()
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(mocks.state.closeModal).toHaveBeenCalledTimes(1)
expect(mocks.state.openSettingsTarget).toHaveBeenCalledWith({
pane: 'ssh',
repoId: null,
sectionId: 'ssh'
})
expect(mocks.state.openSettingsPage).toHaveBeenCalledTimes(1)
})
})
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react'
import { useMemo } from 'react'
import { useAppStore } from '@/store'
import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist'
@@ -24,11 +24,8 @@ export function useAddRepoHostedController(hosted: AddRepoDialogHostedController
* open would hide the navigation behind a stale project selection. */
closeForFolderHandoff: () => void
finishProjectAdd: ((repoId: string) => Promise<void>) | undefined
handleOpenSshSettings: () => void
} {
const storeCloseModal = useAppStore((s) => s.closeModal)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const hostedOnOpenChange = hosted?.onOpenChange
const hostedOnProjectAdded = hosted?.onProjectAdded
// Why: hosted mode (nested inside the workspace composer) must close only
@@ -58,15 +55,5 @@ export function useAddRepoHostedController(hosted: AddRepoDialogHostedController
: storeCloseModal,
[hostedOnOpenChange, storeCloseModal]
)
const handleOpenSshSettings = useCallback((): void => {
closeModal()
// Why: Settings is a full page; in hosted mode the composer modal in the
// activeModal slot would otherwise stay open on top of it.
if (hostedOnOpenChange) {
storeCloseModal()
}
openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' })
openSettingsPage()
}, [closeModal, hostedOnOpenChange, openSettingsPage, openSettingsTarget, storeCloseModal])
return { closeModal, closeForFolderHandoff, finishProjectAdd, handleOpenSshSettings }
return { closeModal, closeForFolderHandoff, finishProjectAdd }
}
@@ -1,62 +0,0 @@
import { useCallback } from 'react'
import { track } from '@/lib/telemetry'
import { buildNestedRepoScanTelemetry } from '../../../../shared/nested-repo-telemetry'
import type { NestedRepoScanResult } from '../../../../shared/project-group-types'
export function useAddRepoRemoteNestedScan({
setActiveNestedScanId,
showNestedRepoReview
}: {
setActiveNestedScanId: (scanId: string | null, runtimeEnvironmentId?: string | null) => void
showNestedRepoReview: (options: {
scan: NestedRepoScanResult
selectedPath: string
connectionId: string
attemptId: string
runtimeKind: 'ssh'
inProgress: boolean
scanId: string | null
runtimeEnvironmentId?: string | null
}) => void
}) {
const showRemoteNestedRepoReview = useCallback(
(
scan: NestedRepoScanResult,
selectedPath: string,
connectionId: string,
attemptId: string,
inProgress: boolean,
scanId: string | null
) => {
setActiveNestedScanId(inProgress ? scanId : null, null)
showNestedRepoReview({
scan,
selectedPath,
connectionId,
attemptId,
runtimeKind: 'ssh',
inProgress,
scanId,
runtimeEnvironmentId: null
})
},
[setActiveNestedScanId, showNestedRepoReview]
)
const trackRemoteNestedScanResult = useCallback(
(scan: NestedRepoScanResult | null, attemptId: string) => {
track(
'add_repo_nested_scan_result',
buildNestedRepoScanTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind: 'ssh',
scan
})
)
},
[]
)
return { showRemoteNestedRepoReview, trackRemoteNestedScanResult }
}
@@ -14,7 +14,6 @@ const mocks = vi.hoisted(() => ({
projects: [],
projectHostSetups: []
},
cloneRemote: vi.fn(),
cloneLocal: vi.fn(),
pickDirectory: vi.fn(),
onCloneProgress: vi.fn(() => vi.fn()),
@@ -102,7 +101,6 @@ describe('useAddRepoCloneFlow', () => {
vi.stubGlobal('window', {
api: {
repos: {
cloneRemote: mocks.cloneRemote,
clone: mocks.cloneLocal,
pickDirectory: mocks.pickDirectory,
onCloneProgress: mocks.onCloneProgress
@@ -111,75 +109,17 @@ describe('useAddRepoCloneFlow', () => {
})
})
it('clones through the selected SSH target', async () => {
const repo = makeRepo({ connectionId: 'ssh-1' })
mocks.cloneRemote.mockResolvedValue(repo)
mocks.callRuntimeRpc.mockReset()
mocks.fetchWorktrees.mockResolvedValue(true)
const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow')
const result = useAddRepoCloneFlow({
step: 'clone',
activeRuntimeEnvironmentId: null,
sshTargetId: 'ssh-1',
workspaceDir: '/local/workspace',
fetchWorktrees: mocks.fetchWorktrees,
onGitRepoReady: mocks.onGitRepoReady
})
await result.handleClone()
expect(mocks.cloneRemote).toHaveBeenCalledWith({
connectionId: 'ssh-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/srv'
})
expect(mocks.cloneLocal).not.toHaveBeenCalled()
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true,
executionHostId: 'ssh:ssh-1'
})
expect(mocks.storeState.repos).toContainEqual({
...repo,
executionHostId: 'ssh:ssh-1'
})
expect(mocks.storeState.projects).toEqual(
expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })])
)
expect(mocks.storeState.projectHostSetups).toEqual(
expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })])
)
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'clone_url', 'ssh:ssh-1')
})
it('does not prefill SSH clone destinations from the local workspace directory', async () => {
mocks.stateValues = ['https://github.com/stablyai/orca.git', '', false, null, null]
const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow')
const result = useAddRepoCloneFlow({
step: 'clone',
activeRuntimeEnvironmentId: null,
sshTargetId: 'ssh-1',
workspaceDir: '/private/tmp/orca-setup-e2e.hOWO1f',
fetchWorktrees: mocks.fetchWorktrees,
onGitRepoReady: mocks.onGitRepoReady
})
expect(result.cloneDestination).toBe('')
expect(mocks.stateSetters[1]).not.toHaveBeenCalledWith('/private/tmp/orca-setup-e2e.hOWO1f')
})
it('strips Electron IPC wrappers from clone errors', async () => {
const cloneError =
'Clone failed: Destination already exists and is not empty: /srv/orca. Choose a different parent folder, delete the existing folder, or add the existing repository instead.'
mocks.cloneRemote.mockRejectedValue(
new Error(`Error invoking remote method 'repos:cloneRemote': Error: ${cloneError}`)
mocks.cloneLocal.mockRejectedValue(
new Error(`Error invoking remote method 'repos:clone': Error: ${cloneError}`)
)
const { useAddRepoCloneFlow } = await import('./useAddRepoCloneFlow')
const result = useAddRepoCloneFlow({
step: 'clone',
activeRuntimeEnvironmentId: null,
sshTargetId: 'ssh-1',
workspaceDir: '/local/workspace',
fetchWorktrees: mocks.fetchWorktrees,
onGitRepoReady: mocks.onGitRepoReady
@@ -204,7 +144,6 @@ describe('useAddRepoCloneFlow', () => {
const result = useAddRepoCloneFlow({
step: 'clone',
activeRuntimeEnvironmentId: 'env-1',
sshTargetId: null,
workspaceDir: '/local/workspace',
fetchWorktrees: mocks.fetchWorktrees,
onGitRepoReady: mocks.onGitRepoReady
@@ -221,7 +160,6 @@ describe('useAddRepoCloneFlow', () => {
{ timeoutMs: 10 * 60_000 }
)
expect(mocks.cloneLocal).not.toHaveBeenCalled()
expect(mocks.cloneRemote).not.toHaveBeenCalled()
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true,
executionHostId: 'runtime:env-1'
@@ -241,7 +179,6 @@ describe('useAddRepoCloneFlow', () => {
step: 'clone',
hostId: null,
activeRuntimeEnvironmentId: null,
sshTargetId: null,
workspaceDir: '/local/workspace',
fetchWorktrees: mocks.fetchWorktrees,
onGitRepoReady: mocks.onGitRepoReady
@@ -249,6 +186,5 @@ describe('useAddRepoCloneFlow', () => {
await result.handleClone()
expect(mocks.cloneLocal).not.toHaveBeenCalled()
expect(mocks.cloneRemote).not.toHaveBeenCalled()
})
})
@@ -15,7 +15,6 @@ export function useAddRepoCloneFlow({
step,
hostId,
activeRuntimeEnvironmentId,
sshTargetId,
workspaceDir,
fetchWorktrees,
onGitRepoReady
@@ -24,11 +23,13 @@ export function useAddRepoCloneFlow({
/** The dialog's chosen host; null while it is unresolved, which blocks the clone. */
hostId?: string | null
activeRuntimeEnvironmentId: string | null | undefined
sshTargetId?: string | null
workspaceDir: string | null | undefined
fetchWorktrees: (
repoId: string,
options?: { requireAuthoritative?: boolean; executionHostId?: ExecutionHostId }
options?: {
requireAuthoritative?: boolean
executionHostId?: ExecutionHostId
}
) => Promise<unknown>
onGitRepoReady: (
repoId: string,
@@ -52,10 +53,11 @@ export function useAddRepoCloneFlow({
const [cloneDestination, setCloneDestination] = useState('')
const [isCloning, setIsCloning] = useState(false)
const [cloneError, setCloneError] = useState<string | null>(null)
const [cloneProgress, setCloneProgress] = useState<{ phase: string; percent: number } | null>(
null
)
const hostToken = `${activeRuntimeEnvironmentId?.trim() ?? ''}:${sshTargetId?.trim() ?? ''}`
const [cloneProgress, setCloneProgress] = useState<{
phase: string
percent: number
} | null>(null)
const hostToken = activeRuntimeEnvironmentId?.trim() ?? ''
const hostTokenRef = useRef(hostToken)
hostTokenRef.current = hostToken
// Why: monotonic ID so stale clone callbacks can detect they were superseded.
@@ -75,7 +77,6 @@ export function useAddRepoCloneFlow({
step,
cloneDestination,
activeRuntimeEnvironmentId,
sshTargetId,
workspaceDir,
cloneStepAutoFilled: cloneStepAutoFilledRef.current
})
@@ -98,9 +99,9 @@ export function useAddRepoCloneFlow({
}, [])
const handlePickDestination = useCallback(async (): Promise<void> => {
if (activeRuntimeEnvironmentId?.trim() || sshTargetId?.trim()) {
if (activeRuntimeEnvironmentId?.trim()) {
// Why: the native folder picker returns a client-local path. Runtime
// and SSH clone destinations must be typed as paths on that host.
// clone destinations must be typed as paths on that host.
toast.error(
translate(
'auto.components.sidebar.useAddRepoCloneFlow.0dc4d1b657',
@@ -115,7 +116,7 @@ export function useAddRepoCloneFlow({
setCloneDestination(dir)
setCloneError(null)
}
}, [activeRuntimeEnvironmentId, sshTargetId])
}, [activeRuntimeEnvironmentId])
const handleClone = useCallback(async (): Promise<void> => {
const trimmedUrl = cloneUrl.trim()
@@ -132,13 +133,8 @@ export function useAddRepoCloneFlow({
setCloneProgress(null)
try {
const target = resolveAddRepoRuntimeTarget(activeRuntimeEnvironmentId)
const repo = sshTargetId?.trim()
? await window.api.repos.cloneRemote({
connectionId: sshTargetId.trim(),
url: trimmedUrl,
destination: cloneDestination.trim()
})
: target.kind === 'environment'
const repo =
target.kind === 'environment'
? (
await callRuntimeRpc<{ repo: Repo }>(
target,
@@ -158,8 +154,7 @@ export function useAddRepoCloneFlow({
return
}
const { repo: ownedRepo } = upsertAddedRepoWithProjectHostSetup(repo, {
runtimeEnvironmentId: activeRuntimeEnvironmentId,
sshConnectionId: sshTargetId
runtimeEnvironmentId: activeRuntimeEnvironmentId
})
toast.success(
translate('auto.components.sidebar.useAddRepoCloneFlow.4d0013cc93', 'Repository cloned'),
@@ -167,7 +162,7 @@ export function useAddRepoCloneFlow({
)
// Why: once the repo exists, a transient non-authoritative refresh
// should fall through to project reveal instead of leaving the add flow open.
const ownerOptions = worktreeRefreshOptions(activeRuntimeEnvironmentId, sshTargetId)
const ownerOptions = worktreeRefreshOptions(activeRuntimeEnvironmentId)
await fetchWorktrees(ownedRepo.id, ownerOptions)
if (!isCurrent()) {
return
@@ -190,8 +185,7 @@ export function useAddRepoCloneFlow({
cloneDestination,
fetchWorktrees,
hostId,
onGitRepoReady,
sshTargetId
onGitRepoReady
])
return {
@@ -4,7 +4,6 @@ import type { ProjectGroupImportResult } from '../../../../shared/project-group-
import type { WorktreeFetchOptions } from '@/store/slices/worktree-helpers'
import { useAddRepoNestedImportFlow } from './useAddRepoNestedImportFlow'
import { useAddRepoNestedReviewState } from './useAddRepoNestedReviewState'
import { useAddRepoRemoteNestedScan } from './use-add-repo-remote-nested-scan'
import type { AddRepoDialogStep } from './add-repo-dialog-types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
@@ -50,17 +49,12 @@ export function useAddRepoNestedReviewController({
| 'handleOpenNestedRootFolder'
| 'resetNestedImportFlow'
| 'trackNestedBackAction'
> &
ReturnType<typeof useAddRepoRemoteNestedScan> {
> {
const review = useAddRepoNestedReviewState({
activeRuntimeEnvironmentId: reviewRuntimeEnvironmentId,
cancelNestedRepoScan,
setStep
})
const remote = useAddRepoRemoteNestedScan({
setActiveNestedScanId: review.setActiveNestedScanId,
showNestedRepoReview: review.showNestedRepoReview
})
const imports = useAddRepoNestedImportFlow({
activeRuntimeEnvironmentId,
closeModal,
@@ -78,5 +72,5 @@ export function useAddRepoNestedReviewController({
nestedRuntimeEnvironmentId: review.nestedRuntimeEnvironmentId,
getNestedRepoRuntimeKind: review.getNestedRepoRuntimeKind
})
return { ...review, ...remote, ...imports }
return { ...review, ...imports }
}
@@ -231,19 +231,6 @@ describe('useCreateProjectDefaults', () => {
expect(setCreateParent).not.toHaveBeenCalled()
})
it('does not use client defaults or Git probing for SSH targets', async () => {
mocks.isGitAvailable.mockResolvedValue(true)
const { setCreateParent } = useHarness({ sshTargetId: 'ssh-1' })
await flushAsync()
expect(setCreateParent).not.toHaveBeenCalled()
expect(mocks.getDefaultCreateProjectParent).not.toHaveBeenCalled()
expect(mocks.isGitAvailable).not.toHaveBeenCalled()
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unknown')
})
it('does nothing outside the create step', async () => {
const { setCreateParent } = useHarness({ step: 'add' })
await flushAsync()
@@ -46,13 +46,11 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
export function useCreateProjectDefaults({
step,
activeRuntimeEnvironmentId,
sshTargetId,
createParent,
setCreateParent
}: {
step: AddRepoDialogStep
activeRuntimeEnvironmentId: string | null | undefined
sshTargetId?: string | null | undefined
createParent: string
setCreateParent: (value: string) => void
}): {
@@ -74,12 +72,9 @@ export function useCreateProjectDefaults({
const createParentDefaultGenRef = useRef(0)
const createGitProbeGenRef = useRef(0)
const activeCreateParentRuntimeEnvironmentId = activeRuntimeEnvironmentId?.trim() || null
const activeCreateParentSshTargetId = sshTargetId?.trim() || null
const activeCreateParentTargetKey = activeCreateParentRuntimeEnvironmentId
? `runtime:${activeCreateParentRuntimeEnvironmentId}`
: activeCreateParentSshTargetId
? `ssh:${activeCreateParentSshTargetId}`
: 'local'
: 'local'
const canReplaceCreateParentDefault = useCallback((parent: string): boolean => {
if (createParentTouchedRef.current) {
@@ -131,7 +126,7 @@ export function useCreateProjectDefaults({
if (step !== 'create') {
return
}
if (activeCreateParentRuntimeEnvironmentId || activeCreateParentSshTargetId) {
if (activeCreateParentRuntimeEnvironmentId) {
return
}
// Why: invalidate any in-flight runtime parent probe once local mode owns the default.
@@ -177,7 +172,6 @@ export function useCreateProjectDefaults({
}, [
activeRuntimeEnvironmentId,
activeCreateParentRuntimeEnvironmentId,
activeCreateParentSshTargetId,
canReplaceCreateParentDefault,
createParent,
setCreateParent,
@@ -189,7 +183,7 @@ export function useCreateProjectDefaults({
return
}
const runtimeEnvironmentId = activeCreateParentRuntimeEnvironmentId
if (!runtimeEnvironmentId || activeCreateParentSshTargetId) {
if (!runtimeEnvironmentId) {
setCreateRuntimeParentStatus('idle')
return
}
@@ -231,8 +225,14 @@ export function useCreateProjectDefaults({
}
const parent = getDefaultCreateProjectParent(result.resolvedPath)
createStepAutoFilledRef.current = true
autoFilledCreateParentRef.current = { parent, targetKey: `runtime:${runtimeEnvironmentId}` }
createParentProvenanceRef.current = { parent, targetKey: `runtime:${runtimeEnvironmentId}` }
autoFilledCreateParentRef.current = {
parent,
targetKey: `runtime:${runtimeEnvironmentId}`
}
createParentProvenanceRef.current = {
parent,
targetKey: `runtime:${runtimeEnvironmentId}`
}
setCreateDefaultParent(parent)
setCreateParent(parent)
setCreateRuntimeParentStatus('idle')
@@ -246,7 +246,6 @@ export function useCreateProjectDefaults({
}, [
activeRuntimeEnvironmentId,
activeCreateParentRuntimeEnvironmentId,
activeCreateParentSshTargetId,
canReplaceCreateParentDefault,
createParent,
setCreateParent,
@@ -259,12 +258,6 @@ export function useCreateProjectDefaults({
}
const runtimeEnvironmentId = activeRuntimeEnvironmentId?.trim()
const gen = ++createGitProbeGenRef.current
if (activeCreateParentSshTargetId) {
// Why: SSH creation happens through the relay; probing client Git would
// make the selected host look healthier or less healthy than it is.
setCreateGitAvailability('unknown')
return
}
setCreateGitAvailability('checking')
const probe = runtimeEnvironmentId
? callRuntimeRpc<{ available: boolean }>(
@@ -291,7 +284,7 @@ export function useCreateProjectDefaults({
}
setCreateGitAvailability('unknown')
})
}, [activeRuntimeEnvironmentId, activeCreateParentSshTargetId, step])
}, [activeRuntimeEnvironmentId, step])
return {
createDefaultParent,
@@ -14,7 +14,6 @@ const mocks = vi.hoisted(() => ({
worktreesByRepo: {} as Record<string, unknown[]>
},
createRepo: vi.fn(),
createRemoteRepo: vi.fn(),
callRuntimeRpc: vi.fn(),
fetchWorktrees: vi.fn(),
onGitRepoReady: vi.fn(),
@@ -112,13 +111,11 @@ describe('useCreateRepo default-checkout handoff', () => {
mocks.storeState.projectHostSetups = []
mocks.storeState.worktreesByRepo = {}
mocks.createRepo.mockReset()
mocks.createRemoteRepo.mockReset()
mocks.storeState.settings.activeRuntimeEnvironmentId = null
vi.stubGlobal('window', {
api: {
repos: {
create: mocks.createRepo,
createRemote: mocks.createRemoteRepo,
pickDirectory: vi.fn()
}
}
@@ -220,35 +217,6 @@ describe('useCreateRepo default-checkout handoff', () => {
expect(mocks.onGitRepoReady).not.toHaveBeenCalled()
})
it('creates projects through the SSH host when an SSH target is selected', async () => {
const repo = makeRepo({ connectionId: 'ssh-1', path: '/srv/created' })
mocks.createRemoteRepo.mockResolvedValue({ repo })
mocks.fetchWorktrees.mockResolvedValue(true)
const { useCreateRepo } = await import('./useCreateRepo')
const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, {
sshTargetId: 'ssh-1'
})
await result.handleCreate()
expect(mocks.createRemoteRepo).toHaveBeenCalledWith({
connectionId: 'ssh-1',
parentPath: '/projects',
name: 'created',
kind: 'git'
})
expect(mocks.createRepo).not.toHaveBeenCalled()
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true,
executionHostId: 'ssh:ssh-1'
})
expect(mocks.storeState.repos).toContainEqual({
...repo,
executionHostId: 'ssh:ssh-1'
})
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'ssh:ssh-1')
})
it('creates projects through the selected runtime environment', async () => {
const repo = makeRepo({ path: '/srv/created' })
mocks.callRuntimeRpc.mockResolvedValue({ repo })
@@ -272,7 +240,6 @@ describe('useCreateRepo default-checkout handoff', () => {
{ timeoutMs: 60_000 }
)
expect(mocks.createRepo).not.toHaveBeenCalled()
expect(mocks.createRemoteRepo).not.toHaveBeenCalled()
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true,
executionHostId: 'runtime:env-1'
@@ -289,12 +256,10 @@ describe('useCreateRepo default-checkout handoff', () => {
const result = useCreateRepo(mocks.fetchWorktrees, vi.fn(), mocks.onGitRepoReady, {
hostId: null,
runtimeEnvironmentId: null,
sshTargetId: null
runtimeEnvironmentId: null
})
await result.handleCreate()
expect(mocks.createRepo).not.toHaveBeenCalled()
expect(mocks.createRemoteRepo).not.toHaveBeenCalled()
})
})
@@ -18,14 +18,16 @@ import type { ExecutionHostId } from '../../../../shared/execution-host'
export function useCreateRepo(
fetchWorktrees: (
repoId: string,
options?: { requireAuthoritative?: boolean; executionHostId?: ExecutionHostId }
options?: {
requireAuthoritative?: boolean
executionHostId?: ExecutionHostId
}
) => Promise<boolean>,
closeModal: () => void,
onGitRepoReady?: (repoId: string, executionHostId?: ExecutionHostId) => void | Promise<void>,
options: {
hostId?: string | null
runtimeEnvironmentId?: string | null
sshTargetId?: string | null
} = {}
) {
const [createName, setCreateName] = useState('')
@@ -33,7 +35,7 @@ export function useCreateRepo(
const [createError, setCreateError] = useState<string | null>(null)
const [isCreating, setIsCreating] = useState(false)
const mountedRef = useMountedRef()
const hostToken = options.hostId ?? options.sshTargetId ?? ''
const hostToken = options.hostId ?? ''
const hostTokenRef = useRef(hostToken)
hostTokenRef.current = hostToken
@@ -51,17 +53,6 @@ export function useCreateRepo(
}, [])
const handlePickParent = useCallback(async (): Promise<string | null> => {
if (options.sshTargetId) {
// Why: the native picker can only browse the client machine. SSH create
// uses a host path typed by the user until remote folder picking exists.
toast.error(
translate(
'auto.components.sidebar.AddRepoCreateStep.ssh_parent_manual',
'Enter an SSH parent path.'
)
)
return null
}
if (options.runtimeEnvironmentId?.trim()) {
// Why: the native folder picker returns a client-local path. Runtime
// project creation needs an explicit host parent path.
@@ -81,7 +72,7 @@ export function useCreateRepo(
return dir
}
return null
}, [mountedRef, options.runtimeEnvironmentId, options.sshTargetId])
}, [mountedRef, options.runtimeEnvironmentId])
const handleCreate = useCallback(async () => {
const name = createName.trim()
@@ -104,14 +95,8 @@ export function useCreateRepo(
// Why: Create Project is intentionally Git-only; non-Git folders use the
// existing add-folder flows instead of this path.
const createKind = 'git' as const
const result = options.sshTargetId
? await window.api.repos.createRemote({
connectionId: options.sshTargetId,
parentPath,
name,
kind: createKind
})
: target.kind === 'environment'
const result =
target.kind === 'environment'
? await callRuntimeRpc<{ repo: Repo } | { error: string }>(
target,
'repo.create',
@@ -139,8 +124,7 @@ export function useCreateRepo(
const { alreadyPresent: wasDeduped, repo } = upsertAddedRepoWithProjectHostSetup(
result.repo,
{
runtimeEnvironmentId: options.runtimeEnvironmentId,
sshConnectionId: options.sshTargetId
runtimeEnvironmentId: options.runtimeEnvironmentId
}
)
// Why: the IPC handler dedupes by path (see repos:create) and returns
@@ -165,7 +149,7 @@ export function useCreateRepo(
}
)
}
const ownerOptions = worktreeRefreshOptions(options.runtimeEnvironmentId, options.sshTargetId)
const ownerOptions = worktreeRefreshOptions(options.runtimeEnvironmentId)
if (isGitRepoKind(repo)) {
// Why: Git repos use the shared default-checkout completion path.
// Why: if refresh is temporarily non-authoritative, the shared opener
@@ -179,7 +163,9 @@ export function useCreateRepo(
// Why: folder repos skip the Git default-checkout handoff, so activate the synthetic
// root workspace before closing. Matches addNonGitFolder's behavior.
await (ownerOptions.executionHostId
? fetchWorktrees(repo.id, { executionHostId: ownerOptions.executionHostId })
? fetchWorktrees(repo.id, {
executionHostId: ownerOptions.executionHostId
})
: fetchWorktrees(repo.id))
if (!isCurrent()) {
return
@@ -222,8 +208,7 @@ export function useCreateRepo(
closeModal,
onGitRepoReady,
options.hostId,
options.runtimeEnvironmentId,
options.sshTargetId
options.runtimeEnvironmentId
])
return {
+2 -16
View File
@@ -5322,8 +5322,7 @@
"AddProjectFromFolderDialog": {
"7d1f51678c": "Add Project",
"7726a16374": "Cancel",
"046751dbfb": "Add this folder as a separate Orca project.",
"e643b30398": "Project added on SSH host"
"046751dbfb": "Add this folder as a separate Orca project."
},
"AddRepoCreateStep": {
"0ae45b8238": "my-project",
@@ -5340,7 +5339,6 @@
"5e97f0c4b9": "Project created",
"2c12db1511": "Project already added",
"875dda0995": "Enter a host parent path.",
"ssh_parent_manual": "Enter an SSH parent path.",
"45b7c26034": "Create project",
"85085d74d2": "Creating…"
},
@@ -5382,13 +5380,9 @@
"e52454b7f6": "Open as Folder"
},
"AddRepoRemoteStep": {
"5b205b5281": "Stop scan",
"6680289908": "/home/user/project",
"ef410aa881": "Host path",
"0416bde073": "Add in Settings",
"df6fbcf880": "No SSH targets configured.",
"44637f43bd": "SSH target",
"80557be85a": "Choose a connected SSH target and enter the path to a Git repository.",
"serverDescription": "Choose an SSH host that {{server}} connects to, and enter the path to a Git repository on it.",
"serverEmpty": "{{server}} has no SSH hosts yet.",
"serverAddHost": "Add SSH host",
@@ -5396,10 +5390,7 @@
"007651bdf9": "Navigate to a directory and click Select to choose it.",
"dd3ff65486": "Browse remote filesystem",
"36d427bb66": "Add project on SSH host",
"35831a7312": "Adding...",
"lockedDescription": "Enter the path to a Git repository on {{value0}}.",
"lockedDisconnected": "{{value0}} is disconnected.",
"93e0221434": "Connect"
"35831a7312": "Adding..."
},
"AddRepoServerStartStep": {
"ae990c86a0": "Back to add options",
@@ -6131,12 +6122,8 @@
"serverSshTitle": "Project on this server's SSH host",
"serverSshDescription": "Open a project folder from an SSH host this server connects to",
"serverSshUpdate": "Update this server to add projects on its SSH hosts",
"sshCreateUnavailable": "Not available for SSH hosts yet",
"3d162cc76f": "Project on SSH host",
"fb4fc5380e": "Local project, Git repo, or folder with many repos",
"2281fdc8c7": "Browse folder",
"sshBrowseTitle": "Open project on SSH host",
"sshBrowseDescription": "Existing Git repository or folder on this SSH host",
"runtimeBrowseDescription": "Existing Git repository or folder on this host"
}
}
@@ -18283,7 +18270,6 @@
"imageSaving": "Saving pasted image…",
"worktreeNotReady": "Worktree not ready — try again in a moment.",
"pasteUnavailable": "Can't paste — this chat isn't accepting input right now.",
"uploadingAttachments": "Uploading {{value0}} file(s) to remote…",
"model": "Model",
"effort": "Effort",
"fastMode": "Fast mode",
+3 -17
View File
@@ -5070,8 +5070,7 @@
"AddProjectFromFolderDialog": {
"7d1f51678c": "Agregar proyecto",
"7726a16374": "Cancelar",
"046751dbfb": "Agrega esta carpeta como un proyecto de Orca separado.",
"e643b30398": "Proyecto agregado en host SSH"
"046751dbfb": "Agrega esta carpeta como un proyecto de Orca separado."
},
"AddRepoCreateStep": {
"0ae45b8238": "my-project",
@@ -5089,8 +5088,7 @@
"fe1e616c5b": "Git es necesario para crear un proyecto.",
"c234df77f7": "Elige o introduce una carpeta padre del host antes de crear.",
"3a13f6e88b": "ubicación no seleccionada",
"6ed14c0281": "carpeta del host no seleccionada",
"ssh_parent_manual": "Introduce una ruta padre SSH."
"6ed14c0281": "carpeta del host no seleccionada"
},
"AddRepoNestedImportStep": {
"496f68cf8c": "Escaneando repositorios. Haz clic para detener.",
@@ -5115,13 +5113,9 @@
"e52454b7f6": "Abrir como carpeta"
},
"AddRepoRemoteStep": {
"5b205b5281": "Detener escaneo",
"6680289908": "/inicio/usuario/proyecto",
"ef410aa881": "Ruta del host",
"0416bde073": "Agregar en Ajustes",
"df6fbcf880": "No hay destinos SSH configurados.",
"44637f43bd": "Destino SSH",
"80557be85a": "Elige un destino SSH conectado e introduce la ruta a un repositorio Git.",
"serverDescription": "Elige un host SSH al que se conecta {{server}} e introduce la ruta a un repositorio Git en él.",
"serverEmpty": "{{server}} aún no tiene hosts SSH.",
"serverAddHost": "Añadir host SSH",
@@ -5129,10 +5123,7 @@
"007651bdf9": "Navega hasta un directorio y haz clic en Seleccionar para elegirlo.",
"dd3ff65486": "Explorar el sistema de archivos remoto",
"36d427bb66": "Agregar proyecto en host SSH",
"35831a7312": "Agregando...",
"lockedDescription": "Introduce la ruta a un repositorio Git en {{value0}}.",
"lockedDisconnected": "{{value0}} está desconectado.",
"93e0221434": "Conectar"
"35831a7312": "Agregando..."
},
"AddRepoServerStartStep": {
"ae990c86a0": "Volver a opciones de agregado",
@@ -5882,12 +5873,8 @@
"serverSshTitle": "Proyecto en un host SSH de este servidor",
"serverSshDescription": "Abre una carpeta de proyecto de un host SSH al que se conecta este servidor",
"serverSshUpdate": "Actualiza este servidor para añadir proyectos en sus hosts SSH",
"3d162cc76f": "Proyecto remoto",
"fb4fc5380e": "Proyecto local, repositorio de Git o carpeta con muchos repositorios",
"2281fdc8c7": "Explorar carpeta",
"sshCreateUnavailable": "Aún no disponible para hosts SSH",
"sshBrowseTitle": "Abrir proyecto en host SSH",
"sshBrowseDescription": "Repositorio Git o carpeta existente en este host SSH",
"runtimeBrowseDescription": "Repositorio Git o carpeta existente en este host"
}
}
@@ -17934,7 +17921,6 @@
"imagePasteFailed": "Error al pegar imagen.",
"worktreeNotReady": "Worktree no está listo — intenta de nuevo en un momento.",
"pasteUnavailable": "No se puede pegar — este chat no acepta entradas en este momento.",
"uploadingAttachments": "Subiendo {{value0}} archivo(s) al remoto…",
"model": "Modelo",
"effort": "Esfuerzo",
"fastMode": "Modo rápido",
+2 -16
View File
@@ -5176,8 +5176,7 @@
"AddProjectFromFolderDialog": {
"7d1f51678c": "Ajouter un projet",
"7726a16374": "Annuler",
"046751dbfb": "Ajoutez ce dossier comme projet Orca distinct.",
"e643b30398": "Projet ajouté sur l'hôte SSH"
"046751dbfb": "Ajoutez ce dossier comme projet Orca distinct."
},
"AddRepoCreateStep": {
"0ae45b8238": "my-project",
@@ -5194,7 +5193,6 @@
"5e97f0c4b9": "Projet créé",
"2c12db1511": "Projet déjà ajouté",
"875dda0995": "Saisissez un chemin parent sur l'hôte.",
"ssh_parent_manual": "Saisissez un chemin parent SSH.",
"45b7c26034": "Créer le projet",
"85085d74d2": "Création…"
},
@@ -5236,13 +5234,9 @@
"e52454b7f6": "Ouvrir comme dossier"
},
"AddRepoRemoteStep": {
"5b205b5281": "Interrompre l'analyse",
"6680289908": "/home/user/project",
"ef410aa881": "Chemin sur l'hôte",
"0416bde073": "Ajouter dans les paramètres",
"df6fbcf880": "Aucune cible SSH configurée.",
"44637f43bd": "Cible SSH",
"80557be85a": "Choisissez une cible SSH connectée et saisissez le chemin vers un dépôt Git.",
"serverDescription": "Choisissez un hôte SSH auquel {{server}} se connecte, puis saisissez le chemin d’un dépôt Git sur cet hôte.",
"serverEmpty": "{{server}} n’a pas encore d’hôte SSH.",
"serverAddHost": "Ajouter un hôte SSH",
@@ -5250,10 +5244,7 @@
"007651bdf9": "Naviguez jusqu'à un répertoire et cliquez sur Sélectionner pour le choisir.",
"dd3ff65486": "Parcourir le système de fichiers distant",
"36d427bb66": "Ajouter un projet sur l'hôte SSH",
"35831a7312": "Ajout...",
"lockedDescription": "Saisissez le chemin vers un dépôt Git sur {{value0}}.",
"lockedDisconnected": "{{value0}} est déconnecté.",
"93e0221434": "Se connecter"
"35831a7312": "Ajout..."
},
"AddRepoServerStartStep": {
"ae990c86a0": "Retour aux options d'ajout",
@@ -5985,12 +5976,8 @@
"serverSshTitle": "Projet sur un hôte SSH de ce serveur",
"serverSshDescription": "Ouvrir un dossier de projet depuis un hôte SSH auquel ce serveur se connecte",
"serverSshUpdate": "Mettez ce serveur à jour pour ajouter des projets sur ses hôtes SSH",
"sshCreateUnavailable": "Pas encore disponible pour les hôtes SSH",
"3d162cc76f": "Projet sur hôte SSH",
"fb4fc5380e": "Projet local, dépôt Git ou dossier contenant plusieurs dépôts",
"2281fdc8c7": "Parcourir le dossier",
"sshBrowseTitle": "Ouvrir un projet sur l'hôte SSH",
"sshBrowseDescription": "Dépôt Git existant ou dossier sur cet hôte SSH",
"runtimeBrowseDescription": "Dépôt Git existant ou dossier sur cette machine"
}
}
@@ -18048,7 +18035,6 @@
"imagePasteFailed": "Échec du collage de l'image.",
"worktreeNotReady": "Worktree pas encore prêt — réessayez dans un instant.",
"pasteUnavailable": "Impossible de coller — ce chat n'accepte pas de saisie pour le moment.",
"uploadingAttachments": "Envoi de {{value0}} fichier(s) vers le distant…",
"model": "Modèle",
"effort": "Effort",
"fastMode": "Mode rapide",
+3 -17
View File
@@ -5051,8 +5051,7 @@
"AddProjectFromFolderDialog": {
"7d1f51678c": "プロジェクトを追加",
"7726a16374": "キャンセル",
"046751dbfb": "このフォルダーを別の Orca プロジェクトとして追加します。",
"e643b30398": "リモートプロジェクトが追加されました"
"046751dbfb": "このフォルダーを別の Orca プロジェクトとして追加します。"
},
"AddRepoCreateStep": {
"0ae45b8238": "my-project",
@@ -5070,8 +5069,7 @@
"fe1e616c5b": "プロジェクトを作成するには Git が必要です。",
"c234df77f7": "作成する前に、ホストの親フォルダーを選択または入力してください。",
"3a13f6e88b": "場所が選択されていません",
"6ed14c0281": "ホストフォルダーが選択されていません",
"ssh_parent_manual": "SSH の親パスを入力してください。"
"6ed14c0281": "ホストフォルダーが選択されていません"
},
"AddRepoNestedImportStep": {
"496f68cf8c": "リポジトリをスキャンしています。クリックして停止します。",
@@ -5096,13 +5094,9 @@
"e52454b7f6": "フォルダーとして開く"
},
"AddRepoRemoteStep": {
"5b205b5281": "スキャンの停止",
"6680289908": "/home/user/project",
"ef410aa881": "ホストパス",
"0416bde073": "設定を追加",
"df6fbcf880": "SSH ターゲットが設定されていません。",
"44637f43bd": "SSH ターゲット",
"80557be85a": "接続されている SSH ターゲットを選択し、Git リポジトリへのパスを入力します。",
"serverDescription": "{{server}} が接続する SSH ホストを選び、そのホスト上の Git リポジトリのパスを入力してください。",
"serverEmpty": "{{server}} にはまだ SSH ホストがありません。",
"serverAddHost": "SSH ホストを追加",
@@ -5110,10 +5104,7 @@
"007651bdf9": "ディレクトリに移動し、「選択」をクリックして選択します。",
"dd3ff65486": "リモートファイルシステムを参照する",
"36d427bb66": "リモートプロジェクトを追加",
"35831a7312": "追加中…",
"lockedDescription": "{{value0}} 上の Git リポジトリへのパスを入力してください。",
"lockedDisconnected": "{{value0}} は切断されています。",
"93e0221434": "接続"
"35831a7312": "追加中…"
},
"AddRepoServerStartStep": {
"ae990c86a0": "オプションの追加に戻る",
@@ -5863,12 +5854,8 @@
"serverSshTitle": "このサーバーの SSH ホスト上のプロジェクト",
"serverSshDescription": "このサーバーが接続する SSH ホストのプロジェクトフォルダーを開きます",
"serverSshUpdate": "SSH ホストにプロジェクトを追加するには、このサーバーを更新してください",
"3d162cc76f": "SSH ホスト上のプロジェクト",
"fb4fc5380e": "ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー",
"2281fdc8c7": "フォルダーを参照",
"sshCreateUnavailable": "SSH ホストではまだ利用できません",
"sshBrowseTitle": "SSH ホスト上のプロジェクトを開く",
"sshBrowseDescription": "この SSH ホスト上の既存の Git リポジトリまたはフォルダー",
"runtimeBrowseDescription": "このホスト上の既存の Git リポジトリまたはフォルダー"
}
}
@@ -17965,7 +17952,6 @@
"imagePasteFailed": "画像の貼り付けに失敗しました。",
"worktreeNotReady": "ワークツリーが準備できていません — しばらくしてから再試行してください。",
"pasteUnavailable": "貼り付けできません — このチャットは現在入力を受け付けていません。",
"uploadingAttachments": "{{value0}}個のファイルをリモートにアップロード中…",
"model": "モデル",
"effort": "推論レベル",
"fastMode": "高速モード",
+3 -17
View File
@@ -5051,8 +5051,7 @@
"AddProjectFromFolderDialog": {
"7d1f51678c": "프로젝트 추가",
"7726a16374": "취소",
"046751dbfb": "이 폴더를 별도의 Orca 프로젝트로 추가합니다.",
"e643b30398": "원격 프로젝트가 추가되었습니다."
"046751dbfb": "이 폴더를 별도의 Orca 프로젝트로 추가합니다."
},
"AddRepoCreateStep": {
"0ae45b8238": "my-project",
@@ -5070,8 +5069,7 @@
"fe1e616c5b": "프로젝트를 만들려면 Git이 필요합니다.",
"c234df77f7": "만들기 전에 서버 상위 폴더를 선택하거나 입력하세요.",
"3a13f6e88b": "위치가 선택되지 않음",
"6ed14c0281": "서버 폴더가 선택되지 않음",
"ssh_parent_manual": "SSH 상위 경로를 입력하세요."
"6ed14c0281": "서버 폴더가 선택되지 않음"
},
"AddRepoNestedImportStep": {
"496f68cf8c": "repos를 스캔하는 중입니다. 중지하려면 클릭하세요.",
@@ -5096,13 +5094,9 @@
"e52454b7f6": "폴더로 열기"
},
"AddRepoRemoteStep": {
"5b205b5281": "스캔 중지",
"6680289908": "/home/user/project",
"ef410aa881": "원격 경로",
"0416bde073": "설정에 추가",
"df6fbcf880": "구성된 SSH 대상이 없습니다.",
"44637f43bd": "SSH 대상",
"80557be85a": "연결된 SSH 대상을 선택하고 Git 리포지토리 경로를 입력하세요.",
"serverDescription": "{{server}}이(가) 연결하는 SSH 호스트를 선택하고, 그 호스트의 Git 저장소 경로를 입력하세요.",
"serverEmpty": "{{server}}에 아직 SSH 호스트가 없습니다.",
"serverAddHost": "SSH 호스트 추가",
@@ -5110,10 +5104,7 @@
"007651bdf9": "디렉터리로 이동한 후 선택을 클릭하여 선택합니다.",
"dd3ff65486": "원격 파일 시스템 찾아보기",
"36d427bb66": "원격 프로젝트 추가",
"35831a7312": "추가 중...",
"lockedDescription": "{{value0}}의 Git 리포지토리 경로를 입력하세요.",
"lockedDisconnected": "{{value0}} 연결이 끊어졌습니다.",
"93e0221434": "연결"
"35831a7312": "추가 중..."
},
"AddRepoServerStartStep": {
"ae990c86a0": "옵션 추가로 돌아가기",
@@ -5863,12 +5854,8 @@
"serverSshTitle": "이 서버의 SSH 호스트에 있는 프로젝트",
"serverSshDescription": "이 서버가 연결하는 SSH 호스트의 프로젝트 폴더를 엽니다",
"serverSshUpdate": "SSH 호스트에 프로젝트를 추가하려면 이 서버를 업데이트하세요",
"3d162cc76f": "원격 프로젝트",
"fb4fc5380e": "로컬 프로젝트, Git repo 또는 repos가 많은 폴더",
"2281fdc8c7": "폴더 찾아보기",
"sshCreateUnavailable": "아직 SSH 호스트에서는 사용할 수 없습니다.",
"sshBrowseTitle": "SSH 호스트에서 프로젝트 열기",
"sshBrowseDescription": "이 SSH 호스트의 기존 Git 리포지토리 또는 폴더",
"runtimeBrowseDescription": "이 호스트의 기존 Git 리포지토리 또는 폴더"
}
}
@@ -17965,7 +17952,6 @@
"imagePasteFailed": "이미지 붙여넣기 실패.",
"worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.",
"pasteUnavailable": "붙여넣을 수 없습니다 — 이 채팅은 지금 입력을 받을 수 없습니다.",
"uploadingAttachments": "{{value0}}개 파일을 원격으로 업로드 중…",
"model": "모델",
"effort": "추론 수준",
"fastMode": "빠른 모드",
+3 -17
View File
@@ -5051,8 +5051,7 @@
"AddProjectFromFolderDialog": {
"7d1f51678c": "添加项目",
"7726a16374": "取消",
"046751dbfb": "将此文件夹添加为单独的 Orca 项目。",
"e643b30398": "添加了远程项目"
"046751dbfb": "将此文件夹添加为单独的 Orca 项目。"
},
"AddRepoCreateStep": {
"0ae45b8238": "my-project",
@@ -5070,8 +5069,7 @@
"fe1e616c5b": "创建项目需要 Git。",
"c234df77f7": "创建前请选择或输入服务器父文件夹。",
"3a13f6e88b": "未选择位置",
"6ed14c0281": "未选择服务器文件夹",
"ssh_parent_manual": "输入 SSH 父路径。"
"6ed14c0281": "未选择服务器文件夹"
},
"AddRepoNestedImportStep": {
"496f68cf8c": "扫描存储库。单击停止。",
@@ -5096,13 +5094,9 @@
"e52454b7f6": "作为文件夹打开"
},
"AddRepoRemoteStep": {
"5b205b5281": "停止扫描",
"6680289908": "/home/user/project",
"ef410aa881": "远程路径",
"0416bde073": "在设置中添加",
"df6fbcf880": "未配置 SSH 目标。",
"44637f43bd": "SSH 目标",
"80557be85a": "选择已连接的 SSH 目标并输入 Git 存储库的路径。",
"serverDescription": "选择 {{server}} 连接的 SSH 主机,并输入该主机上 Git 仓库的路径。",
"serverEmpty": "{{server}} 还没有 SSH 主机。",
"serverAddHost": "添加 SSH 主机",
@@ -5110,10 +5104,7 @@
"007651bdf9": "导航到目录并单击“选择”以选择它。",
"dd3ff65486": "浏览远程文件系统",
"36d427bb66": "添加远程项目",
"35831a7312": "添加...",
"lockedDescription": "输入 {{value0}} 上 Git 仓库的路径。",
"lockedDisconnected": "{{value0}} 已断开连接。",
"93e0221434": "连接"
"35831a7312": "添加..."
},
"AddRepoServerStartStep": {
"ae990c86a0": "返回添加选项",
@@ -5863,12 +5854,8 @@
"serverSshTitle": "此服务器 SSH 主机上的项目",
"serverSshDescription": "从此服务器连接的 SSH 主机打开项目文件夹",
"serverSshUpdate": "更新此服务器后才能在其 SSH 主机上添加项目",
"3d162cc76f": "远程项目",
"fb4fc5380e": "本地项目、Git 仓库或包含多个仓库的文件夹",
"2281fdc8c7": "浏览文件夹",
"sshCreateUnavailable": "SSH 主机暂不可用",
"sshBrowseTitle": "在 SSH 主机上打开项目",
"sshBrowseDescription": "此 SSH 主机上的现有 Git 仓库或文件夹",
"runtimeBrowseDescription": "此主机上的现有 Git 仓库或文件夹"
}
}
@@ -17944,7 +17931,6 @@
"imagePasteFailed": "图片粘贴失败。",
"worktreeNotReady": "工作树未就绪 — 请稍后重试。",
"pasteUnavailable": "无法粘贴 — 此聊天当前不接受输入。",
"uploadingAttachments": "正在将 {{value0}} 个文件上传到远端…",
"model": "模型",
"effort": "推理强度",
"fastMode": "快速模式",
@@ -22,8 +22,9 @@ export function runtimeTargetEnvironmentId(target: RuntimeClientTarget): string
}
/**
* Transport to the host that owns a row: a server's own runtime, or this app for local and direct
* SSH (which ride this app's IPC with a `connectionId`). `null` when the id names no routable host.
* Transport to the host that owns a row: a server's own runtime, or this app for local and `ssh:`
* rows (this app serves no SSH route, so its IPC refuses those). `null` when the id names no
* routable host.
*/
export function runtimeTargetForOwnerHostId(
hostId: ExecutionHostId | null | undefined
@@ -54,9 +54,15 @@ export function createProjectHostSetupActions(
{ timeoutMs: 15_000 }
)
).result
const repo = adoptFromEndpoint(target, { kind: 'repo', row: result.repo })
const repo = adoptFromEndpoint(target, {
kind: 'repo',
row: result.repo
})
const repoHostId = getRepoExecutionHostId(repo)
const setup = adoptFromEndpoint(target, { kind: 'projectHostSetup', row: result.setup })
const setup = adoptFromEndpoint(target, {
kind: 'projectHostSetup',
row: result.setup
})
const project = normalizeProjectRow(result.project)
set((s) => {
const nextRepos = s.repos.some((entry) =>
@@ -108,7 +114,10 @@ export function createProjectHostSetupActions(
{ timeoutMs: 15_000 }
)
).result
const setup = adoptFromEndpoint(target, { kind: 'projectHostSetup', row: result.setup })
const setup = adoptFromEndpoint(target, {
kind: 'projectHostSetup',
row: result.setup
})
const project = normalizeProjectRow(result.project)
set((s) => ({
projects: s.projects.some((entry) => entry.id === project.id)
@@ -148,7 +157,10 @@ export function createProjectHostSetupActions(
{ timeoutMs: 15_000 }
)
).result
const setup = adoptFromEndpoint(target, { kind: 'projectHostSetup', row: result.setup })
const setup = adoptFromEndpoint(target, {
kind: 'projectHostSetup',
row: result.setup
})
const project = normalizeProjectRow(result.project)
const repo = result.repo
? adoptFromEndpoint(target, { kind: 'repo', row: result.repo })
@@ -242,33 +254,29 @@ export function createProjectHostSetupActions(
setupProjectClone: async (args) => {
try {
const parsedHost = parseExecutionHostId(args.hostId)
const target = getProjectSetupRuntimeTarget(args.hostId)
if (parsedHost?.kind !== 'ssh') {
await assertProjectHostSetupMutationRuntimeCapabilities(target)
// Why: an SSH host clones only through its paired server; never fall back to this computer.
if (parsedHost?.kind === 'ssh') {
throw new Error(`SSH connection "${parsedHost.targetId}" not found or not connected`)
}
const target = getProjectSetupRuntimeTarget(args.hostId)
await assertProjectHostSetupMutationRuntimeCapabilities(target)
const repo =
parsedHost?.kind === 'ssh'
? await window.api.repos.cloneRemote({
connectionId: parsedHost.targetId,
target.kind === 'local'
? await window.api.repos.clone({
url: args.url,
destination: args.destination
})
: target.kind === 'local'
? await window.api.repos.clone({
url: args.url,
destination: args.destination
})
: (
await callRuntimeRpc<{ repo: Repo }>(
target,
'repo.clone',
{
url: args.url,
destination: args.destination
},
{ timeoutMs: 10 * 60_000 }
)
).repo
: (
await callRuntimeRpc<{ repo: Repo }>(
target,
'repo.clone',
{
url: args.url,
destination: args.destination
},
{ timeoutMs: 10 * 60_000 }
)
).repo
return await get().setupProjectExistingFolder({
projectId: args.projectId,
hostId: args.hostId,
+9 -42
View File
@@ -555,58 +555,25 @@ describe('repo slice runtime routing', () => {
})
})
it('clones a project on an SSH host before aligning it as a host setup', async () => {
const project: Project = {
id: 'project-1',
displayName: 'Project',
badgeColor: '#000',
sourceRepoIds: ['ssh-repo'],
createdAt: 1,
updatedAt: 1
}
const clonedRepo = { ...sshRepo, path: '/srv/project' }
const setup: ProjectHostSetup = {
id: clonedRepo.id,
projectId: project.id,
hostId: 'ssh:ssh-1',
repoId: clonedRepo.id,
path: clonedRepo.path,
displayName: clonedRepo.displayName,
setupState: 'ready',
setupMethod: 'cloned',
createdAt: 1,
updatedAt: 1
}
reposCloneRemote.mockResolvedValue(clonedRepo)
projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: clonedRepo })
it('refuses to clone on an SSH host instead of cloning onto this computer', async () => {
const store = createTestStore()
await expect(
store.getState().setupProjectClone({
projectId: project.id,
projectId: 'project-1',
hostId: 'ssh:ssh-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/srv',
displayName: 'Project'
})
).resolves.toEqual({
project,
setup,
repo: { ...clonedRepo, executionHostId: 'ssh:ssh-1' }
})
).resolves.toBeNull()
expect(reposCloneRemote).toHaveBeenCalledWith({
connectionId: 'ssh-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/srv'
})
expect(projectsSetupExistingFolder).toHaveBeenCalledWith({
projectId: project.id,
hostId: 'ssh:ssh-1',
path: clonedRepo.path,
kind: 'git',
displayName: 'Project',
setupMethod: 'cloned'
expect(reposCloneRemote).not.toHaveBeenCalled()
expect(reposClone).not.toHaveBeenCalled()
expect(projectsSetupExistingFolder).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith('Failed to add project', {
description: 'SSH connection "ssh-1" not found or not connected',
duration: expect.any(Number)
})
})