diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f40d1571e0a..e905ab2546b 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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": [ diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 54148a0d4a3..ade12730dee 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -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={} 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') }} diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index b70c299be36..abdd169d4fd 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -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') + }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index ee05b70b433..9a0da649f8c 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -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} diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx index 0d73eca96cd..b9ab6c89068 100644 --- a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx +++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx index 40fc712810a..6fa3b88aceb 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx @@ -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) diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx index 71f8485842b..e5e4b409abc 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx @@ -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(null) const actionsRef = useRef(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(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): 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( diff --git a/src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts b/src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts new file mode 100644 index 00000000000..35ce05a2244 --- /dev/null +++ b/src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/sidebar/add-repo-browse-authority.ts b/src/renderer/src/components/sidebar/add-repo-browse-authority.ts new file mode 100644 index 00000000000..364e21201e4 --- /dev/null +++ b/src/renderer/src/components/sidebar/add-repo-browse-authority.ts @@ -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() + } +} diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts b/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts index effec4b5192..52a77a3b581 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts @@ -7,7 +7,7 @@ export function useAddRepoHostChangeReset({ onResetHostScopedState }: { isOpen: boolean - selectedHostId: string + selectedHostId: string | null onResetClosed: () => void onResetHostScopedState: () => void }) { diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts index b9a77b2e9bd..45a4fb9abbc 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts @@ -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' } diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts index 684fa75d78b..29f1fadc761 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts @@ -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['hostOptions'] - selectedHostId: ExecutionHostId + selectedHostId: ExecutionHostId | null selectedParsedHost: ReturnType 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(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 => { diff --git a/src/renderer/src/store/slices/repos-paired-runtime-add.test.ts b/src/renderer/src/store/slices/repos-paired-runtime-add.test.ts new file mode 100644 index 00000000000..4e0f4cae622 --- /dev/null +++ b/src/renderer/src/store/slices/repos-paired-runtime-add.test.ts @@ -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() + }) +}) diff --git a/tests/e2e/multi-client-navigation-isolation.spec.ts b/tests/e2e/multi-client-navigation-isolation.spec.ts index 399ae5d963f..d0576f00c8c 100644 --- a/tests/e2e/multi-client-navigation-isolation.spec.ts +++ b/tests/e2e/multi-client-navigation-isolation.spec.ts @@ -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() + } +})