Fix paired web Add Project folder browsing (#13885)

This commit is contained in:
Jinwoo Hong
2026-08-11 23:25:50 -07:00
committed by GitHub
parent c45c806220
commit fd2afc16c8
14 changed files with 304 additions and 30 deletions
+33
View File
@@ -3233,6 +3233,7 @@
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/runtime/runtime-server-directory-browser.test.ts src/renderer/src/store/slices/repos-paired-runtime-add.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1",
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line"
@@ -3246,11 +3247,14 @@
"src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts",
"src/renderer/src/components/status-bar/SshStatusSegment.test.ts",
"src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts",
"src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts",
"src/renderer/src/hooks/useIpcEvents.test.ts",
"src/renderer/src/web/web-preload-api.test.ts",
"src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx",
"src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx",
"src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts",
"src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx",
"src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx",
"src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts",
"src/renderer/src/components/sidebar/project-added-default-checkout.test.ts",
"src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts",
@@ -3262,6 +3266,7 @@
"src/renderer/src/lib/resolved-worktree-execution-host.test.ts",
"src/renderer/src/lib/worktree-runtime-owner.test.ts",
"src/renderer/src/store/selectors.test.ts",
"src/renderer/src/store/slices/repos-paired-runtime-add.test.ts",
"src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts",
"src/renderer/src/store/slices/repos-project-groups.test.ts",
"src/renderer/src/store/slices/repos-selected-owner-routing.test.ts",
@@ -3271,6 +3276,34 @@
"tests/e2e/pr11346-selected-runtime-add.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts",
"assertions": [
"paired web hides the viewing computer as an Add Project filesystem authority and selects the paired runtime"
]
},
{
"file": "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts",
"assertions": [
"paired runtime browsing opens the host browser without invoking native pickFolders while desktop local and SSH routes remain intact"
]
},
{
"file": "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx",
"assertions": ["paired runtime browsing renders the in-app host filesystem browser"]
},
{
"file": "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx",
"assertions": [
"host-scoped Add Project actions remain disabled until a paired runtime is selectable"
]
},
{
"file": "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts",
"assertions": [
"an explicitly selected paired runtime submits repo.add to that runtime without native repo or folder-picker calls"
]
},
{
"file": "src/main/ipc/settings.test.ts",
"assertions": [
@@ -18,6 +18,7 @@ import {
useAddRepoHostedController,
type AddRepoDialogHostedController
} from './use-add-repo-hosted-controller'
import { routeAddRepoBrowse } from './add-repo-browse-authority'
export default React.memo(function AddRepoDialog({
hosted
@@ -308,7 +309,7 @@ export default React.memo(function AddRepoDialog({
selectedSshTargetId={hostSelection.selectedSshTargetId}
selectedHostLabel={
hostSelection.hostOptions.find((host) => host.id === hostSelection.selectedHostId)
?.label ?? hostSelection.selectedHostId
?.label ?? null
}
lockSshTargetSelection={hostSelection.selectedParsedHost?.kind === 'ssh'}
remotePath={remotePath}
@@ -324,26 +325,31 @@ export default React.memo(function AddRepoDialog({
isCreating={isCreating}
hostSelector={<AddRepoHostSelectorSlot hostSelection={hostSelection} />}
showRemoteAction={false}
browseHostKind={
selectedHostKind === 'ssh' || selectedHostKind === 'runtime' ? selectedHostKind : 'local'
}
actionsDisabled={!hostSelection.selectedHostId}
browseHostKind={selectedHostKind ?? 'runtime'}
createDefaultParent={createDefaultParent}
createGitAvailability={createGitAvailability}
createRuntimeParentStatus={createRuntimeParentStatus}
createParentDefaultPending={createParentDefaultPending}
manualCreateParentEntry={isRuntimeEnvironmentActive || selectedHostKind === 'ssh'}
onBrowse={
selectedHostKind === 'ssh'
? () => void handleOpenRemoteStep(hostSelection.selectedSshTargetId)
: selectedHostKind === 'runtime'
? () => setStep('server-path')
: handleBrowse
onBrowse={() =>
routeAddRepoBrowse(hostSelection.selectedParsedHost, {
browseLocal: () => void handleBrowse(),
browseRuntime: () => setStep('server-path'),
browseSsh: (targetId) => void handleOpenRemoteStep(targetId)
})
}
onOpenCloneStep={() => {
if (!hostSelection.selectedHostId) {
return
}
setCloneError(null)
setStep('clone')
}}
onOpenCreateStep={() => {
if (!hostSelection.selectedHostId) {
return
}
setCreateError(null)
setStep('create')
}}
@@ -281,4 +281,16 @@ describe('AddRepoDialogStepContent nested imports', () => {
expect(html).not.toContain('Create on host')
expect(html).not.toContain('Want to import many repos at once?')
})
it('opens the in-app filesystem browser for a paired runtime', () => {
const html = renderStepContent({
step: 'server-path',
isRuntimeEnvironmentActive: true,
activeRuntimeEnvironmentId: 'paired-host'
})
expect(html).toContain('Browse host filesystem')
expect(html).toContain('Navigate to a directory and click Select to choose it.')
expect(html).toContain('Select folder')
})
})
@@ -46,6 +46,7 @@ type AddRepoDialogStepContentProps = {
hostSelector?: ReactNode
showRemoteAction?: boolean
canCreateProject?: boolean
actionsDisabled?: boolean
manualCreateParentEntry?: boolean
browseHostKind?: 'local' | 'ssh' | 'runtime'
createDefaultParent: string
@@ -115,6 +116,7 @@ export function AddRepoDialogStepContent({
hostSelector,
showRemoteAction = true,
canCreateProject = true,
actionsDisabled = false,
manualCreateParentEntry = isRuntimeEnvironmentActive,
browseHostKind = 'local',
createDefaultParent,
@@ -159,6 +161,7 @@ export function AddRepoDialogStepContent({
hostSelector={hostSelector}
showRemoteAction={showRemoteAction}
canCreateProject={canCreateProject}
actionsDisabled={actionsDisabled}
browseHostKind={browseHostKind}
onBrowse={onBrowse}
onOpenCloneStep={onOpenCloneStep}
@@ -13,7 +13,7 @@ import { canConnectAddRepoHost, canSelectAddRepoHost } from './add-repo-host-ava
type AddRepoHostSelectorProps = {
hosts: SidebarHostOption[]
selectedHostId: ExecutionHostId
selectedHostId: ExecutionHostId | null
open: boolean
onOpenChange: (open: boolean) => void
onSelectHost: (hostId: ExecutionHostId) => void
@@ -53,6 +53,7 @@ function renderServerPathStartStep(runtimeEnvironmentId: string | null): string
type LocalStartStepDomOptions = {
isAdding?: boolean
actionsDisabled?: boolean
addProjectBusyLabel?: string | null
nestedScanInProgress?: boolean
nestedScanId?: string | null
@@ -76,6 +77,7 @@ async function renderLocalStartStepDom(
repoCount={1}
isSshLikely={isSshLikely}
isAdding={options.isAdding ?? false}
actionsDisabled={options.actionsDisabled ?? false}
addProjectBusyLabel={options.addProjectBusyLabel ?? null}
nestedScanInProgress={options.nestedScanInProgress ?? false}
nestedScanId={options.nestedScanId ?? null}
@@ -257,6 +259,20 @@ describe('AddRepoLocalStartStep', () => {
})
})
it('disables host-scoped actions until a host is selectable', async () => {
const { container, root } = await renderLocalStartStepDom(false, { 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('⏎')
await act(async () => {
root.unmount()
})
})
it('marks the autofocused Browse action as selected with the ⏎ chip', async () => {
const { container, root } = await renderLocalStartStepDom(false)
@@ -69,6 +69,7 @@ type AddRepoLocalStartStepProps = {
hostSelector?: ReactNode
showRemoteAction?: boolean
canCreateProject?: boolean
actionsDisabled?: boolean
browseHostKind?: 'local' | 'ssh' | 'runtime'
onBrowse: () => void
onOpenCloneStep: () => void
@@ -87,6 +88,7 @@ export function AddRepoLocalStartStep({
hostSelector,
showRemoteAction = true,
canCreateProject = true,
actionsDisabled = false,
browseHostKind = 'local',
onBrowse,
onOpenCloneStep,
@@ -96,6 +98,7 @@ export function AddRepoLocalStartStep({
}: AddRepoLocalStartStepProps): React.JSX.Element {
const browseActionRef = useRef<HTMLButtonElement | null>(null)
const actionsRef = useRef<HTMLDivElement | null>(null)
const actionsUnavailable = isAdding || actionsDisabled
const { primaryAction, secondaryActions } = getAddRepoLocalStartActions({
isSshLikely,
onBrowse,
@@ -111,16 +114,13 @@ export function AddRepoLocalStartStep({
// it follows keyboard focus so Enter always activates the highlighted action. Browse is
// autofocused on open, so it starts selected; Tab and ↑/↓ move the highlight.
const [selectedKind, setSelectedKind] = useState<string | null>(primaryAction.kind)
const visibleSelectedKind = actionsUnavailable ? null : selectedKind
useEffect(() => {
if (isAdding) {
setSelectedKind(null)
return
}
if (!isAdding) {
if (!actionsUnavailable) {
browseActionRef.current?.focus()
}
}, [isAdding])
}, [actionsUnavailable])
// ↑/↓ rove focus across the action buttons in visual order; focus drives the selection.
const handleArrowNavigation = (event: React.KeyboardEvent<HTMLDivElement>): void => {
@@ -177,8 +177,8 @@ export function AddRepoLocalStartStep({
icon={primaryAction.icon}
title={primaryAction.title}
description={primaryAction.description}
disabled={isAdding}
selected={selectedKind === primaryAction.kind}
disabled={actionsUnavailable}
selected={visibleSelectedKind === primaryAction.kind}
buttonRef={browseActionRef}
onClick={primaryAction.onClick}
onFocus={() => setSelectedKind(primaryAction.kind)}
@@ -199,8 +199,8 @@ export function AddRepoLocalStartStep({
icon={action.icon}
title={action.title}
description={action.description}
disabled={isAdding || Boolean(action.disabled)}
selected={selectedKind === action.kind}
disabled={actionsUnavailable || Boolean(action.disabled)}
selected={visibleSelectedKind === action.kind}
onClick={action.onClick}
onFocus={() => setSelectedKind(action.kind)}
className={cn(
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { routeAddRepoBrowse } from './add-repo-browse-authority'
describe('routeAddRepoBrowse', () => {
it('opens paired host browsing without invoking native pickFolders', () => {
const pickFolders = vi.fn()
const browseRuntime = vi.fn()
routeAddRepoBrowse(parseExecutionHostId('runtime:paired-host'), {
browseLocal: pickFolders,
browseRuntime,
browseSsh: vi.fn()
})
expect(browseRuntime).toHaveBeenCalledOnce()
expect(pickFolders).not.toHaveBeenCalled()
})
it('preserves native folder browsing for desktop local hosts', () => {
const pickFolders = vi.fn()
routeAddRepoBrowse(parseExecutionHostId('local'), {
browseLocal: pickFolders,
browseRuntime: vi.fn(),
browseSsh: vi.fn()
})
expect(pickFolders).toHaveBeenCalledOnce()
})
it('preserves SSH host browsing', () => {
const browseSsh = vi.fn()
routeAddRepoBrowse(parseExecutionHostId('ssh:builder'), {
browseLocal: vi.fn(),
browseRuntime: vi.fn(),
browseSsh
})
expect(browseSsh).toHaveBeenCalledWith('builder')
})
})
@@ -0,0 +1,24 @@
import type { ParsedExecutionHost } from '../../../../shared/execution-host'
export type AddRepoBrowseAuthorityActions = {
browseLocal: () => void
browseRuntime: () => void
browseSsh: (targetId: string) => void
}
export function routeAddRepoBrowse(
host: ParsedExecutionHost | null,
actions: AddRepoBrowseAuthorityActions
): void {
if (host?.kind === 'runtime') {
actions.browseRuntime()
return
}
if (host?.kind === 'ssh') {
actions.browseSsh(host.targetId)
return
}
if (host?.kind === 'local') {
actions.browseLocal()
}
}
@@ -7,7 +7,7 @@ export function useAddRepoHostChangeReset({
onResetHostScopedState
}: {
isOpen: boolean
selectedHostId: string
selectedHostId: string | null
onResetClosed: () => void
onResetHostScopedState: () => void
}) {
@@ -17,7 +17,8 @@ const mocks = vi.hoisted(() => ({
runtimeEnvironments: [] as { id: string; name: string; source?: 'manual' | 'ephemeral-vm' }[]
},
sshConnect: vi.fn(),
sshGetState: vi.fn()
sshGetState: vi.fn(),
isWebClient: false
}))
vi.mock('react', async (importOriginal) => {
@@ -64,6 +65,10 @@ vi.mock('./use-sidebar-host-scope-options', () => ({
useSidebarHostScopeOptions: () => ({ hostOptions: mocks.hostOptions })
}))
vi.mock('@/lib/web-client-location', () => ({
isWebClientLocation: () => mocks.isWebClient
}))
describe('useAddRepoHostSelection', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -103,6 +108,7 @@ describe('useAddRepoHostSelection', () => {
mocks.storeState.runtimeEnvironments = []
mocks.sshConnect.mockReset()
mocks.sshGetState.mockReset()
mocks.isWebClient = false
vi.stubGlobal('window', {
api: {
ssh: {
@@ -137,6 +143,35 @@ describe('useAddRepoHostSelection', () => {
expect(setStep).toHaveBeenCalledWith('add')
})
it('uses the paired runtime as the only local filesystem authority in web clients', async () => {
mocks.isWebClient = true
mocks.stateValues = ['local', false]
const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection')
const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() })
expect(result.hostOptions.map((host) => host.id)).toEqual(['ssh:ssh-1', 'runtime:env-1'])
expect(result.selectedHostId).toBe('runtime:env-1')
expect(result.selectedParsedHost).toMatchObject({
kind: 'runtime',
environmentId: 'env-1'
})
})
it('has no local fallback while a paired web runtime is loading', async () => {
mocks.isWebClient = true
mocks.stateValues = ['local', false]
mocks.hostOptions = [mocks.hostOptions[0]]
const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection')
const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() })
expect(result.hostOptions).toEqual([])
expect(result.selectedHostId).toBeNull()
expect(result.selectedParsedHost).toBeNull()
expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('local')
})
it('selects a local or SSH host without changing the durable active server', async () => {
mocks.stateValues = ['runtime:env-1', false]
mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' }
@@ -13,6 +13,7 @@ import type { AddRepoDialogStep } from './add-repo-dialog-types'
import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options'
import { canSelectAddRepoHost } from './add-repo-host-availability'
import { translate } from '@/i18n/i18n'
import { isWebClientLocation } from '@/lib/web-client-location'
export function useAddRepoHostSelection({
isOpen,
@@ -22,7 +23,7 @@ export function useAddRepoHostSelection({
setStep: (step: AddRepoDialogStep) => void
}): {
hostOptions: ReturnType<typeof useSidebarHostScopeOptions>['hostOptions']
selectedHostId: ExecutionHostId
selectedHostId: ExecutionHostId | null
selectedParsedHost: ReturnType<typeof parseExecutionHostId>
selectedSshTargetId: string | null
hostSelectorOpen: boolean
@@ -35,6 +36,7 @@ export function useAddRepoHostSelection({
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const { hostOptions } = useSidebarHostScopeOptions()
const isWebClient = isWebClientLocation()
const ephemeralRuntimeEnvironmentIds = useMemo(
() =>
new Set(
@@ -49,26 +51,31 @@ export function useAddRepoHostSelection({
hostOptions.filter((host) => {
const parsed = parseExecutionHostId(host.id)
return (
parsed?.kind !== 'runtime' || !ephemeralRuntimeEnvironmentIds.has(parsed.environmentId)
!(isWebClient && parsed?.kind === 'local') &&
(parsed?.kind !== 'runtime' || !ephemeralRuntimeEnvironmentIds.has(parsed.environmentId))
)
}),
[ephemeralRuntimeEnvironmentIds, hostOptions]
[ephemeralRuntimeEnvironmentIds, hostOptions, isWebClient]
)
const [selectedAddProjectHostId, setSelectedAddProjectHostId] =
useState<ExecutionHostId>(LOCAL_EXECUTION_HOST_ID)
const [hostSelectorOpen, setHostSelectorOpen] = useState(false)
const previousOpenRef = useRef(false)
const pairedWebRuntimeHost = isWebClient
? selectableHostOptions.find((host) => host.kind === 'runtime' && canSelectAddRepoHost(host))
: undefined
const selectedHost =
selectableHostOptions.find(
(host) => host.id === selectedAddProjectHostId && canSelectAddRepoHost(host)
) ??
pairedWebRuntimeHost ??
selectableHostOptions.find(
(host) => host.id === LOCAL_EXECUTION_HOST_ID && canSelectAddRepoHost(host)
) ??
selectableHostOptions.find((host) => canSelectAddRepoHost(host)) ??
selectableHostOptions[0]
const selectedHostId = selectedHost?.id ?? LOCAL_EXECUTION_HOST_ID
const selectedHostId = selectedHost?.id ?? (isWebClient ? null : LOCAL_EXECUTION_HOST_ID)
const selectedParsedHost = parseExecutionHostId(selectedHostId)
const selectedSshTargetId =
selectedParsedHost?.kind === 'ssh' ? selectedParsedHost.targetId : null
@@ -80,14 +87,16 @@ export function useAddRepoHostSelection({
(host) => host.id === focusedHostId && canSelectAddRepoHost(host)
)
? focusedHostId
: LOCAL_EXECUTION_HOST_ID
setSelectedAddProjectHostId(nextHostId)
: (pairedWebRuntimeHost?.id ?? (isWebClient ? null : LOCAL_EXECUTION_HOST_ID))
if (nextHostId) {
setSelectedAddProjectHostId(nextHostId)
}
}
if (!isOpen) {
setHostSelectorOpen(false)
}
previousOpenRef.current = isOpen
}, [isOpen, selectableHostOptions, settings])
}, [isOpen, isWebClient, pairedWebRuntimeHost?.id, selectableHostOptions, settings])
const handleSelectAddProjectHost = useCallback(
async (hostId: ExecutionHostId): Promise<void> => {
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest'
import { createTestStore } from './store-test-helpers'
import {
installReposRuntimeRoutingHarness,
remoteRepo,
reposAdd,
reposPickFolder,
runtimeEnvironmentCall
} from './repos-runtime-routing-fixture'
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn()
}
}))
installReposRuntimeRoutingHarness()
describe('paired runtime repo add routing', () => {
it('submits the selected host path to repo.add without native filesystem calls', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-paired-add',
ok: true,
result: { repo: remoteRepo },
_meta: { runtimeId: 'runtime-remote' }
})
const store = createTestStore()
await store.getState().addRepoPath('/srv/paired-project', 'folder', {
runtimeEnvironmentId: 'paired-host'
})
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'paired-host',
method: 'repo.add',
params: { path: '/srv/paired-project', kind: 'folder' },
timeoutMs: 15_000
})
expect(reposAdd).not.toHaveBeenCalled()
expect(reposPickFolder).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,7 @@
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { mkdtempSync, rmSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
@@ -176,3 +178,49 @@ test('keeps two paired browser clients and the host on independent worktrees', a
await clientA?.close()
}
})
test('routes Add Project folder browsing through the paired host', async ({
electronApp,
orcaPage,
registerPostElectronShutdownCleanup
}) => {
const hostFolder = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-web-folder-'))
const folderName = path.basename(hostFolder)
registerPostElectronShutdownCleanup(async () => {
rmSync(hostFolder, { recursive: true, force: true })
})
const visibleWorktreeId = await orcaPage.evaluate(
() => window.__store?.getState().activeWorktreeId
)
if (!visibleWorktreeId) {
throw new Error('Host worktree was not active before paired web validation')
}
const offer = await createPairingOffer(orcaPage)
const client = await openPairedClient(electronApp, offer, visibleWorktreeId)
try {
await client
.getByRole('button', { name: /Add Project/i })
.first()
.click()
const addDialog = client.getByRole('dialog', { name: /Add a project/i })
await expect(addDialog).toBeVisible()
await expect(addDialog).not.toContainText('Local Mac')
await addDialog.getByRole('button', { name: /Browse folder/i }).click()
const browser = client.getByRole('dialog', { name: /Browse host filesystem/i })
await expect(browser).toBeVisible()
await expect(browser.getByRole('button', { name: /Select folder/i })).toBeVisible()
await browser.getByRole('button', { name: /^Cancel$/i }).click()
const manualPathDialog = client.getByRole('dialog', { name: /Open host project/i })
await manualPathDialog.locator('#server-project-path').fill(hostFolder)
await manualPathDialog.getByRole('button', { name: /Open as Folder/i }).click()
await expect(manualPathDialog).toBeHidden({ timeout: 30_000 })
await expect(
client.locator('[data-worktree-sidebar]').getByText(folderName, { exact: true }).first()
).toBeVisible({ timeout: 30_000 })
} finally {
await client.close()
}
})