mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
+2
-18
@@ -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
|
||||
|
||||
+6
-63
@@ -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',
|
||||
|
||||
+3
-51
@@ -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
-25
@@ -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()
|
||||
)
|
||||
|
||||
+6
-166
@@ -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) {
|
||||
|
||||
+56
-92
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "高速モード",
|
||||
|
||||
@@ -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": "빠른 모드",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user