From 626f2ec0edbd55dee40acb428d7f7be558d70214 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 10 Jun 2026 22:45:31 -0700 Subject: [PATCH] Make add project host aware Co-authored-by: Orca --- notes/project-first-host-model.md | 16 +- src/main/runtime/orca-runtime.ts | 1 + .../src/components/sidebar/AddRepoDialog.tsx | 46 ++++-- .../sidebar/AddRepoDialogStepContent.tsx | 14 +- .../sidebar/AddRepoHostSelector.tsx | 120 +++++++++++++++ .../sidebar/AddRepoServerStartStep.tsx | 124 ++++++++++++--- .../sidebar/AddRepoStartSteps.test.tsx | 28 ++++ .../components/sidebar/AddRepoStartSteps.tsx | 15 +- .../src/components/sidebar/AddRepoSteps.tsx | 145 ++++++++++++------ .../sidebar/add-repo-local-start-actions.ts | 60 ++++++-- .../sidebar/use-add-repo-host-selection.ts | 87 +++++++++++ .../components/sidebar/useAddRepoCloneFlow.ts | 31 +++- src/renderer/src/i18n/locales/en.json | 7 + src/renderer/src/i18n/locales/ja.json | 7 + src/renderer/src/i18n/locales/ko.json | 7 + src/renderer/src/i18n/locales/zh.json | 7 + 16 files changed, 614 insertions(+), 101 deletions(-) create mode 100644 src/renderer/src/components/sidebar/AddRepoHostSelector.tsx create mode 100644 src/renderer/src/components/sidebar/use-add-repo-host-selection.ts diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index 3c58766b40e..a2a93b6eec6 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -809,6 +809,12 @@ Landed so far: narrow relay `git.clone` RPC that emits `git.cloneProgress` notifications, and the main process forwards those through the existing `repos:clone-progress` renderer event used by local clone. +- Started the host-aware Add Project migration. The Add Project start screen now + shows a host selector when more than Local Mac is known, uses the shared + sidebar host registry for labels/status, routes Browse to local picker, + selected runtime server, or preselected SSH target, and lets Clone from URL + clone on local, runtime, or SSH hosts. The old separate "Remote project" start + row is hidden in the host-aware flow. - Added tests for local repos, SSH repos, same-provider multi-host grouping, no-identity same-name non-grouping, selector cache behavior, persistence backfill, repo mutation synchronization, renderer hydration, runtime RPC @@ -842,12 +848,20 @@ Important limitation: first-class setup create/update/delete now exist for independent metadata and are exposed through the renderer store/settings, but actual provisioning execution and full UI flows are still future work. +- Add Project is now host-aware for single-host Browse and Clone flows, but + multi-host setup rows are not yet implemented in the Add Project dialog. SSH + "Create new project" is visibly unavailable because the app still lacks a + first-class SSH create-project IPC/API equivalent; runtime create is already + handled through the existing runtime RPC path. Remaining end-to-end work: - continue broadening setup-on-host flows beyond the shared known-host registry toward bulk setup and newly added host onboarding -- finish provisioning and bulk setup-on-host flows +- finish provisioning, bulk setup-on-host flows, and Add Project multi-host + setup rows with per-host paths +- add a first-class SSH create-project path so Create can target SSH hosts, not + just local and runtime hosts - add actual provisioning execution APIs instead of only recording independent setup/provisioning metadata and intent - split settings into explicit client, host, project, and project-host setup diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 07ed509c5f8..93385dc3aad 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -52,6 +52,7 @@ import type { PersistedUIState, Project, ProjectHostSetup, + ProjectHostSetupCloneArgs, ProjectHostSetupCreateArgs, ProjectHostSetupCreateResult, ProjectHostSetupDeleteArgs, diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index f9975630ded..a608901df8a 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -19,6 +19,8 @@ import { shouldTrackAddRepoExistingWorkspacesDetected } from './add-repo-existing-workspaces-telemetry' import { finishProjectAddWithDefaultCheckout } from './project-added-default-checkout' +import { AddRepoHostSelector } from './AddRepoHostSelector' +import { useAddRepoHostSelection } from './use-add-repo-host-selection' const AddRepoDialog = React.memo(function AddRepoDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -34,8 +36,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const settings = useAppStore((s) => s.settings) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const [step, setStep] = useState('add') const [isAdding, setIsAdding] = useState(false) @@ -142,6 +142,16 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { } ) + const { + hostOptions, + selectedHostId, + selectedParsedHost, + selectedSshTargetId, + hostSelectorOpen, + setHostSelectorOpen, + handleSelectAddProjectHost + } = useAddRepoHostSelection({ isOpen: activeModal === 'add-repo', setStep }) + const { createName, createParent, @@ -174,6 +184,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { } = useAddRepoCloneFlow({ step, activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + sshTargetId: selectedSshTargetId, workspaceDir: settings?.workspaceDir, fetchWorktrees, onGitRepoReady: completeGitRepoAdd @@ -183,13 +194,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const droppedLocalPath = typeof modalData.droppedLocalPath === 'string' ? modalData.droppedLocalPath : '' const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) - // Why: repo_added telemetry cannot reliably separate SSH from local folder adds, - // so promote remote projects from durable local SSH state instead. - const isSshLikely = - repos.some((repo) => Boolean(repo.connectionId)) || - sshTargetLabels.size > 0 || - Array.from(sshConnectionStates.values()).some((state) => state.status === 'connected') - const { handleBrowse, resetLocalFolderFlow } = useAddRepoLocalFolderFlow({ isOpen, droppedLocalPath, @@ -279,6 +283,16 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { resetState() }, [resetState, step, trackNestedBackAction]) + const hostSelector = ( + void handleSelectAddProjectHost(hostId)} + /> + ) + return ( void handleOpenRemoteStep(selectedSshTargetId) + : handleBrowse + } onOpenCloneStep={() => { setCloneError(null) setStep('clone') }} onOpenCreateStep={() => { + if (selectedParsedHost?.kind === 'ssh') { + return + } setCreateError(null) setStep('create') }} diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index 8172131de76..acf43266984 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -1,4 +1,4 @@ -import type { Dispatch, SetStateAction } from 'react' +import type { Dispatch, ReactNode, SetStateAction } from 'react' import { CloneStep } from './AddRepoSteps' import { RemoteStep } from './AddRepoRemoteStep' import { CreateStep } from './AddRepoCreateStep' @@ -40,10 +40,13 @@ type AddRepoDialogStepContentProps = { createKind: 'git' | 'folder' createError: string | null isCreating: boolean + hostSelector?: ReactNode + showRemoteAction?: boolean + canCreateProject?: boolean onBrowse: () => void onOpenCloneStep: () => void onOpenCreateStep: () => void - onOpenRemoteStep: () => void + onOpenRemoteStep: (targetId?: string | null) => void onStopNestedScan: () => void onServerPathChange: (path: string) => void onAddServerPath: (kind: 'git' | 'folder') => void @@ -98,6 +101,9 @@ export function AddRepoDialogStepContent({ createKind, createError, isCreating, + hostSelector, + showRemoteAction = true, + canCreateProject = true, onBrowse, onOpenCloneStep, onOpenCreateStep, @@ -131,6 +137,7 @@ export function AddRepoDialogStepContent({ runtimeEnvironmentId={activeRuntimeEnvironmentId} isAddingServerPath={isAddingServerPath} addProjectBusyLabel={addProjectBusyLabel} + hostSelector={hostSelector} onServerPathChange={onServerPathChange} onAddServerPath={onAddServerPath} onOpenCloneStep={onOpenCloneStep} @@ -148,6 +155,9 @@ export function AddRepoDialogStepContent({ addProjectBusyLabel={addProjectBusyLabel} nestedScanInProgress={nestedScanInProgress} nestedScanId={nestedScanId} + hostSelector={hostSelector} + showRemoteAction={showRemoteAction} + canCreateProject={canCreateProject} onBrowse={onBrowse} onOpenCloneStep={onOpenCloneStep} onOpenRemoteStep={onOpenRemoteStep} diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx new file mode 100644 index 00000000000..43e6f193372 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx @@ -0,0 +1,120 @@ +import { Check, ChevronsUpDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Command, CommandItem, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import type { SidebarHostOption } from './sidebar-host-options' +import { getSidebarHostHealthLabel, shouldShowHostScopeControls } from './sidebar-host-options' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { translate } from '@/i18n/i18n' + +type AddRepoHostSelectorProps = { + hosts: SidebarHostOption[] + selectedHostId: ExecutionHostId + open: boolean + onOpenChange: (open: boolean) => void + onSelectHost: (hostId: ExecutionHostId) => void +} + +function getHostKindLabel(host: SidebarHostOption): string { + switch (host.kind) { + case 'local': + return translate('auto.components.sidebar.AddRepoHostSelector.local', 'Local') + case 'ssh': + return translate('auto.components.sidebar.AddRepoHostSelector.ssh', 'SSH') + case 'runtime': + return translate('auto.components.sidebar.AddRepoHostSelector.runtime', 'Server') + } +} + +function isHostDisabled(host: SidebarHostOption): boolean { + return host.health === 'blocked' +} + +export function AddRepoHostSelector({ + hosts, + selectedHostId, + open, + onOpenChange, + onSelectHost +}: AddRepoHostSelectorProps): React.JSX.Element | null { + if (!shouldShowHostScopeControls(hosts)) { + return null + } + + const selectedHost = hosts.find((host) => host.id === selectedHostId) ?? hosts[0] + if (!selectedHost) { + return null + } + + return ( +
+
+ + {translate('auto.components.sidebar.AddRepoHostSelector.host', 'Host')} + + + + + + + + + {hosts.map((host) => { + const selected = host.id === selectedHostId + const disabled = isHostDisabled(host) + return ( + { + if (disabled) { + return + } + onSelectHost(host.id) + onOpenChange(false) + }} + className="items-start gap-2 px-3 py-2 text-xs" + > + + + + {host.label} + + {getHostKindLabel(host)} + + + + {getSidebarHostHealthLabel(host.health)} + {host.detail ? ` - ${host.detail}` : ''} + + + + ) + })} + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx b/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx index 2667032d3c3..45dc41f662f 100644 --- a/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoServerStartStep.tsx @@ -1,4 +1,4 @@ -import { useState, type ComponentType } from 'react' +import { useState, type ComponentType, type ReactNode } from 'react' import { FolderOpen, Globe, Lightbulb, Loader2, Server } from 'lucide-react' import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -12,6 +12,7 @@ type AddRepoServerPathStartStepProps = { runtimeEnvironmentId: string | null | undefined isAddingServerPath: boolean addProjectBusyLabel: string | null + hostSelector?: ReactNode onServerPathChange: (path: string) => void onAddServerPath: (kind: 'git' | 'folder') => void onOpenCloneStep: () => void @@ -23,6 +24,7 @@ export function AddRepoServerPathStartStep({ runtimeEnvironmentId, isAddingServerPath, addProjectBusyLabel, + hostSelector, onServerPathChange, onAddServerPath, onOpenCloneStep, @@ -35,9 +37,18 @@ export function AddRepoServerPathStartStep({ return ( <> - {translate("auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d", "Browse server filesystem")} + + {translate( + 'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d', + 'Browse server filesystem' + )} + - {translate("auto.components.sidebar.AddRepoServerStartStep.0f8aba944c", "Navigate to a directory and click Select to choose it.")} + {translate( + 'auto.components.sidebar.AddRepoServerStartStep.0f8aba944c', + 'Navigate to a directory and click Select to choose it.' + )} + - {translate("auto.components.sidebar.AddRepoServerStartStep.39bd249b3a", "Add a project")} + + {translate( + 'auto.components.sidebar.AddRepoServerStartStep.39bd249b3a', + 'Add a project' + )} + - {translate("auto.components.sidebar.AddRepoServerStartStep.8efa930eb5", "Add another project from the selected runtime server.")} + {translate( + 'auto.components.sidebar.AddRepoServerStartStep.8efa930eb5', + 'Add another project from the selected runtime server.' + )} +
+ {hostSelector}
setBrowsing(true)} /> @@ -94,7 +133,11 @@ export function AddRepoServerPathStartStep({ - {translate("auto.components.sidebar.AddRepoServerStartStep.6b9958492a", "Want to import many repos at once? Browse to the parent folder.")} + {translate( + 'auto.components.sidebar.AddRepoServerStartStep.6b9958492a', + 'Want to import many repos at once? Browse to the parent folder.' + )} +
+ {translate( + 'auto.components.sidebar.AddRepoServerStartStep.438493f214', + 'Or enter a server path manually' + )} +
) @@ -112,24 +159,38 @@ export function AddRepoServerPathStartStep({ return ( <> - {translate("auto.components.sidebar.AddRepoServerStartStep.3d0c035483", "Open server project")} + + {translate( + 'auto.components.sidebar.AddRepoServerStartStep.3d0c035483', + 'Open server project' + )} + - {translate("auto.components.sidebar.AddRepoServerStartStep.423b5d3d31", "Add a Git repository or folder that already exists on the selected runtime server.")} + {translate( + 'auto.components.sidebar.AddRepoServerStartStep.423b5d3d31', + 'Add a Git repository or folder that already exists on the selected runtime server.' + )} +
+ {hostSelector}
+ {translate('auto.components.sidebar.AddRepoServerStartStep.867692f505', 'Server path')} +
onServerPathChange(event.target.value)} - placeholder={translate("auto.components.sidebar.AddRepoServerStartStep.92d25420a0", "/home/user/project")} + placeholder={translate( + 'auto.components.sidebar.AddRepoServerStartStep.92d25420a0', + '/home/user/project' + )} className="h-11 min-w-0 flex-1 font-mono text-sm" disabled={isAddingServerPath} autoFocus @@ -144,13 +205,20 @@ export function AddRepoServerPathStartStep({ className="h-11 w-11 shrink-0" onClick={() => setBrowsing(true)} disabled={isAddingServerPath || !runtimeEnvironmentId} - aria-label={translate("auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d", "Browse server filesystem")} + aria-label={translate( + 'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d', + 'Browse server filesystem' + )} > - {translate("auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d", "Browse server filesystem")} + {translate( + 'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d', + 'Browse server filesystem' + )} +
@@ -160,14 +228,22 @@ export function AddRepoServerPathStartStep({ disabled={!serverPath.trim() || isAddingServerPath} className="h-10" > - {translate("auto.components.sidebar.AddRepoServerStartStep.8da4d1a5be", "Add Git Project")} + {translate( + 'auto.components.sidebar.AddRepoServerStartStep.8da4d1a5be', + 'Add Git Project' + )} + + {translate( + 'auto.components.sidebar.AddRepoServerStartStep.e1710bf831', + 'Open as Folder' + )} +
{isAddingServerPath && addProjectBusyLabel ? (
@@ -181,7 +257,11 @@ export function AddRepoServerPathStartStep({ disabled={isAddingServerPath} className="mx-auto block rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default disabled:opacity-40" > - {translate("auto.components.sidebar.AddRepoServerStartStep.ae990c86a0", "Back to add options")} + {translate( + 'auto.components.sidebar.AddRepoServerStartStep.ae990c86a0', + 'Back to add options' + )} +
) diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx index ace7b22bc85..6d498c57f72 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx @@ -120,6 +120,27 @@ function getActionTitles(isSshLikely: boolean): { } } +function getHostAwareActionModel(): { + secondary: string[] + createDisabled: boolean | undefined +} { + const { secondaryActions } = getAddRepoLocalStartActions({ + isSshLikely: true, + showRemoteAction: false, + canCreateProject: 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 + } +} + describe('AddRepoLocalStartStep', () => { afterEach(() => { document.body.innerHTML = '' @@ -159,6 +180,13 @@ describe('AddRepoLocalStartStep', () => { expect(titles.secondary).toEqual(['Remote project', '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(true) + }) + it('focuses Browse folder when the default Add Project step opens', async () => { const { container, root } = await renderLocalStartStepDom(false) const browseButton = findButton(container, 'Browse folder') diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx index 01e26e0b0df..1935875f20c 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ComponentType, type Ref } from 'react' +import { useEffect, useRef, useState, type ComponentType, type ReactNode, type Ref } from 'react' import { CircleStop, Loader2 } from 'lucide-react' import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -66,6 +66,9 @@ type AddRepoLocalStartStepProps = { addProjectBusyLabel: string | null nestedScanInProgress: boolean nestedScanId: string | null + hostSelector?: ReactNode + showRemoteAction?: boolean + canCreateProject?: boolean onBrowse: () => void onOpenCloneStep: () => void onOpenRemoteStep: () => void @@ -80,6 +83,9 @@ export function AddRepoLocalStartStep({ addProjectBusyLabel, nestedScanInProgress, nestedScanId, + hostSelector, + showRemoteAction = true, + canCreateProject = true, onBrowse, onOpenCloneStep, onOpenRemoteStep, @@ -93,7 +99,9 @@ export function AddRepoLocalStartStep({ onBrowse, onOpenCloneStep, onOpenRemoteStep, - onOpenCreateStep + onOpenCreateStep, + showRemoteAction, + canCreateProject }) // The white fill + ⏎ chip is a roving selection indicator, not a fixed "primary" badge: @@ -161,6 +169,7 @@ export function AddRepoLocalStartStep({ onBlur={handleActionsBlur} onKeyDown={handleArrowNavigation} > + {hostSelector} setSelectedKind(action.kind)} diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index af55e2448ea..41d3349de85 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -67,37 +67,47 @@ export function useRemoteRepo( void cancelNestedRepoScan(remoteNestedScanId) }, [cancelNestedRepoScan, remoteNestedScanId]) - const handleOpenRemoteStep = useCallback(async () => { - const gen = ++remoteGenRef.current - setStep('remote') - try { - const targets = (await window.api.ssh.listTargets()) as SshTarget[] - if (gen !== remoteGenRef.current) { - return + const handleOpenRemoteStep = useCallback( + async (preferredTargetId?: string | null) => { + const gen = ++remoteGenRef.current + setStep('remote') + try { + const targets = (await window.api.ssh.listTargets()) as SshTarget[] + if (gen !== remoteGenRef.current) { + return + } + const withState = await Promise.all( + targets.map(async (t) => { + const state = (await window.api.ssh.getState({ + targetId: t.id + })) as SshConnectionState | null + return { ...t, state: state ?? undefined } + }) + ) + if (gen !== remoteGenRef.current) { + return + } + setSshTargets(withState) + const preferred = preferredTargetId + ? withState.find((t) => t.id === preferredTargetId) + : undefined + const connected = withState.find((t) => t.state?.status === 'connected') + if (preferred) { + setSelectedTargetId(preferred.id) + return + } + if (connected) { + setSelectedTargetId(connected.id) + } + } catch { + if (gen !== remoteGenRef.current) { + return + } + setSshTargets([]) } - const withState = await Promise.all( - targets.map(async (t) => { - const state = (await window.api.ssh.getState({ - targetId: t.id - })) as SshConnectionState | null - return { ...t, state: state ?? undefined } - }) - ) - if (gen !== remoteGenRef.current) { - return - } - setSshTargets(withState) - const connected = withState.find((t) => t.state?.status === 'connected') - if (connected) { - setSelectedTargetId(connected.id) - } - } catch { - if (gen !== remoteGenRef.current) { - return - } - setSshTargets([]) - } - }, [setStep]) + }, + [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 @@ -116,7 +126,11 @@ export function useRemoteRepo( try { await window.api.ssh.connect({ targetId }) } catch (err) { - toast.error(err instanceof Error ? err.message : translate("auto.components.sidebar.AddRepoSteps.3e64e8a70d", "Connection failed")) + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.sidebar.AddRepoSteps.3e64e8a70d', 'Connection failed') + ) } }, []) @@ -189,7 +203,10 @@ export function useRemoteRepo( if (!mountedRef.current || gen !== remoteGenRef.current) { return } - toast.success(translate("auto.components.sidebar.AddRepoSteps.df8b0e6c22", "Remote project added"), { description: repo.displayName }) + toast.success( + translate('auto.components.sidebar.AddRepoSteps.df8b0e6c22', 'Remote project added'), + { description: repo.displayName } + ) // Why: the repo is already persisted here; if SSH refresh is temporarily // non-authoritative, finish onto the project row instead of stranding the dialog. await fetchWorktrees(repo.id, { requireAuthoritative: true }) @@ -293,9 +310,18 @@ export function CloneStep({ return ( <> - {translate("auto.components.sidebar.AddRepoSteps.a93ef169b5", "Browse server filesystem")} + + {translate( + 'auto.components.sidebar.AddRepoSteps.a93ef169b5', + 'Browse server filesystem' + )} + - {translate("auto.components.sidebar.AddRepoSteps.fe8e629fe3", "Navigate to a directory and click Select to choose it.")} + {translate( + 'auto.components.sidebar.AddRepoSteps.fe8e629fe3', + 'Navigate to a directory and click Select to choose it.' + )} + - {translate("auto.components.sidebar.AddRepoSteps.c05f88a31f", "Clone from URL")} - {translate("auto.components.sidebar.AddRepoSteps.5b2ea674b1", "Enter the Git URL and choose where to clone it.")} + + {translate('auto.components.sidebar.AddRepoSteps.c05f88a31f', 'Clone from URL')} + + + {translate( + 'auto.components.sidebar.AddRepoSteps.5b2ea674b1', + 'Enter the Git URL and choose where to clone it.' + )} +
- + onUrlChange(e.target.value)} onKeyDown={handleKeyDown} - placeholder={translate("auto.components.sidebar.AddRepoSteps.b698a4a29d", "https://github.com/user/repo.git")} + placeholder={translate( + 'auto.components.sidebar.AddRepoSteps.b698a4a29d', + 'https://github.com/user/repo.git' + )} className="h-8 text-xs" disabled={isCloning} autoFocus @@ -332,13 +370,18 @@ export function CloneStep({
- +
onDestChange(e.target.value)} onKeyDown={handleKeyDown} - placeholder={translate("auto.components.sidebar.AddRepoSteps.2ce3f6edf8", "/path/to/destination")} + placeholder={translate( + 'auto.components.sidebar.AddRepoSteps.2ce3f6edf8', + '/path/to/destination' + )} className="h-8 text-xs flex-1" disabled={isCloning} /> @@ -354,8 +397,22 @@ export function CloneStep({ onPickDestination() }} disabled={isCloning || (disableDestinationPicker && !runtimeEnvironmentId)} - title={runtimeEnvironmentId ? translate("auto.components.sidebar.AddRepoSteps.a93ef169b5", "Browse server filesystem") : translate("auto.components.sidebar.AddRepoSteps.569326d9cc", "Choose folder")} - aria-label={runtimeEnvironmentId ? translate("auto.components.sidebar.AddRepoSteps.a93ef169b5", "Browse server filesystem") : translate("auto.components.sidebar.AddRepoSteps.569326d9cc", "Choose folder")} + title={ + runtimeEnvironmentId + ? translate( + 'auto.components.sidebar.AddRepoSteps.a93ef169b5', + 'Browse server filesystem' + ) + : translate('auto.components.sidebar.AddRepoSteps.569326d9cc', 'Choose folder') + } + aria-label={ + runtimeEnvironmentId + ? translate( + 'auto.components.sidebar.AddRepoSteps.a93ef169b5', + 'Browse server filesystem' + ) + : translate('auto.components.sidebar.AddRepoSteps.569326d9cc', 'Choose folder') + } > @@ -369,7 +426,9 @@ export function CloneStep({ disabled={!cloneUrl.trim() || !cloneDestination.trim() || isCloning} className="w-full" > - {isCloning ? translate("auto.components.sidebar.AddRepoSteps.69f5b5380d", "Cloning...") : translate("auto.components.sidebar.AddRepoSteps.32a7256d85", "Clone")} + {isCloning + ? translate('auto.components.sidebar.AddRepoSteps.69f5b5380d', 'Cloning...') + : translate('auto.components.sidebar.AddRepoSteps.32a7256d85', 'Clone')} {/* Why: progress bar lives below the button so it doesn't push the diff --git a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts index 005b29940df..0c26e88e887 100644 --- a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts +++ b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts @@ -7,6 +7,8 @@ export type AddRepoLocalStartActionHandlers = { onOpenCloneStep: () => void onOpenRemoteStep: () => void onOpenCreateStep: () => void + showRemoteAction?: boolean + canCreateProject?: boolean } export type AddRepoLocalStartAction = { @@ -14,6 +16,7 @@ export type AddRepoLocalStartAction = { icon: ComponentType<{ className?: string }> title: string description: string + disabled?: boolean onClick: () => void } @@ -22,7 +25,9 @@ export function getAddRepoLocalStartActions({ onBrowse, onOpenCloneStep, onOpenRemoteStep, - onOpenCreateStep + onOpenCreateStep, + showRemoteAction = true, + canCreateProject = true }: { isSshLikely: boolean } & AddRepoLocalStartActionHandlers): { primaryAction: AddRepoLocalStartAction secondaryActions: AddRepoLocalStartAction[] @@ -30,35 +35,68 @@ export function getAddRepoLocalStartActions({ const primaryAction = { kind: 'browse' as const, icon: FolderOpen, - title: translate("auto.components.sidebar.add.repo.local.start.actions.2281fdc8c7", "Browse folder"), - description: translate("auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e", "Local project, Git repo, or folder with many repos"), + title: translate( + 'auto.components.sidebar.add.repo.local.start.actions.2281fdc8c7', + 'Browse folder' + ), + description: translate( + 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', + 'Local project, Git repo, or folder with many repos' + ), onClick: onBrowse } const remote = { kind: 'remote' as const, icon: Monitor, - title: translate("auto.components.sidebar.add.repo.local.start.actions.3d162cc76f", "Remote project"), - description: translate("auto.components.sidebar.add.repo.local.start.actions.a6c20dca96", "Open a project from an SSH target"), + title: translate( + 'auto.components.sidebar.add.repo.local.start.actions.3d162cc76f', + 'Remote project' + ), + description: translate( + 'auto.components.sidebar.add.repo.local.start.actions.a6c20dca96', + 'Open a project from an SSH target' + ), onClick: onOpenRemoteStep } const clone = { kind: 'clone' as const, icon: Globe, - title: translate("auto.components.sidebar.add.repo.local.start.actions.7edb8ebe24", "Clone from URL"), - description: translate("auto.components.sidebar.add.repo.local.start.actions.5f9ffac036", "Clone a remote Git repository"), + title: translate( + 'auto.components.sidebar.add.repo.local.start.actions.7edb8ebe24', + 'Clone from URL' + ), + description: translate( + 'auto.components.sidebar.add.repo.local.start.actions.5f9ffac036', + 'Clone a remote Git repository' + ), onClick: onOpenCloneStep } const create = { kind: 'create' as const, icon: Plus, - title: translate("auto.components.sidebar.add.repo.local.start.actions.c709860596", "Create new project"), - description: translate("auto.components.sidebar.add.repo.local.start.actions.d72789705e", "Start from an empty folder"), + title: translate( + '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, onClick: onOpenCreateStep } - // SSH-likely users reach for remote targets first, so surface that row ahead of clone. - const secondaryActions = isSshLikely ? [remote, clone, create] : [clone, remote, create] + const secondaryActions = showRemoteAction + ? isSshLikely + ? [remote, clone, create] + : [clone, remote, create] + : [clone, create] return { primaryAction, secondaryActions } } 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 new file mode 100644 index 00000000000..2ed45c8bf4d --- /dev/null +++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { + getSettingsFocusedExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import type { AddRepoDialogStep } from './add-repo-dialog-types' +import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options' + +export function useAddRepoHostSelection({ + isOpen, + setStep +}: { + isOpen: boolean + setStep: (step: AddRepoDialogStep) => void +}): { + hostOptions: ReturnType['hostOptions'] + selectedHostId: ExecutionHostId + selectedParsedHost: ReturnType + selectedSshTargetId: string | null + hostSelectorOpen: boolean + setHostSelectorOpen: (open: boolean) => void + handleSelectAddProjectHost: (hostId: ExecutionHostId) => Promise +} { + const settings = useAppStore((s) => s.settings) + const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment) + const { hostOptions } = useSidebarHostScopeOptions() + const [selectedAddProjectHostId, setSelectedAddProjectHostId] = + useState(LOCAL_EXECUTION_HOST_ID) + const [hostSelectorOpen, setHostSelectorOpen] = useState(false) + const previousOpenRef = useRef(false) + + const selectedHost = + hostOptions.find((host) => host.id === selectedAddProjectHostId) ?? + hostOptions.find((host) => host.id === LOCAL_EXECUTION_HOST_ID) ?? + hostOptions[0] + const selectedHostId = selectedHost?.id ?? LOCAL_EXECUTION_HOST_ID + const selectedParsedHost = parseExecutionHostId(selectedHostId) + const selectedSshTargetId = + selectedParsedHost?.kind === 'ssh' ? selectedParsedHost.targetId : null + + useEffect(() => { + if (isOpen && !previousOpenRef.current) { + const focusedHostId = getSettingsFocusedExecutionHostId(settings) + const nextHostId = hostOptions.some((host) => host.id === focusedHostId) + ? focusedHostId + : LOCAL_EXECUTION_HOST_ID + setSelectedAddProjectHostId(nextHostId) + } + if (!isOpen) { + setHostSelectorOpen(false) + } + previousOpenRef.current = isOpen + }, [hostOptions, isOpen, settings]) + + const handleSelectAddProjectHost = useCallback( + async (hostId: ExecutionHostId): Promise => { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'runtime') { + const switched = await switchRuntimeEnvironment(parsed.environmentId) + if (!switched) { + return + } + } else if (settings?.activeRuntimeEnvironmentId?.trim()) { + const switched = await switchRuntimeEnvironment(null) + if (!switched) { + return + } + } + setSelectedAddProjectHostId(hostId) + setStep('add') + }, + [settings?.activeRuntimeEnvironmentId, setStep, switchRuntimeEnvironment] + ) + + return { + hostOptions, + selectedHostId, + selectedParsedHost, + selectedSshTargetId, + hostSelectorOpen, + setHostSelectorOpen, + handleSelectAddProjectHost + } +} diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts index ba1dc380eea..ee5d67f2364 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts @@ -11,12 +11,14 @@ import { translate } from '@/i18n/i18n' export function useAddRepoCloneFlow({ step, activeRuntimeEnvironmentId, + sshTargetId, workspaceDir, fetchWorktrees, onGitRepoReady }: { step: AddRepoDialogStep activeRuntimeEnvironmentId: string | null | undefined + sshTargetId?: string | null workspaceDir: string | null | undefined fetchWorktrees: (repoId: string, options?: { requireAuthoritative?: boolean }) => Promise onGitRepoReady: (repoId: string, source: AddRepoExistingWorkspaceSource) => Promise @@ -79,10 +81,15 @@ export function useAddRepoCloneFlow({ }, []) const handlePickDestination = useCallback(async (): Promise => { - if (activeRuntimeEnvironmentId?.trim()) { + if (activeRuntimeEnvironmentId?.trim() || sshTargetId?.trim()) { // Why: the native folder picker returns a client-local path. Runtime - // clone destinations must be typed as server paths. - toast.error(translate("auto.components.sidebar.useAddRepoCloneFlow.0dc4d1b657", "Enter a server path for the clone destination.")) + // and SSH clone destinations must be typed as paths on that host. + toast.error( + translate( + 'auto.components.sidebar.useAddRepoCloneFlow.0dc4d1b657', + 'Enter a server path for the clone destination.' + ) + ) return } const gen = cloneGenRef.current @@ -91,7 +98,7 @@ export function useAddRepoCloneFlow({ setCloneDestination(dir) setCloneError(null) } - }, [activeRuntimeEnvironmentId]) + }, [activeRuntimeEnvironmentId, sshTargetId]) const handleClone = useCallback(async (): Promise => { const trimmedUrl = cloneUrl.trim() @@ -104,8 +111,13 @@ export function useAddRepoCloneFlow({ setCloneProgress(null) try { const target = getActiveRuntimeTarget(useAppStore.getState().settings) - const repo = - target.kind === 'environment' + const repo = sshTargetId?.trim() + ? await window.api.repos.cloneRemote({ + connectionId: sshTargetId.trim(), + url: trimmedUrl, + destination: cloneDestination.trim() + }) + : target.kind === 'environment' ? ( await callRuntimeRpc<{ repo: Repo }>( target, @@ -124,7 +136,10 @@ export function useAddRepoCloneFlow({ if (gen !== cloneGenRef.current) { return } - toast.success(translate("auto.components.sidebar.useAddRepoCloneFlow.4d0013cc93", "Repository cloned"), { description: repo.displayName }) + toast.success( + translate('auto.components.sidebar.useAddRepoCloneFlow.4d0013cc93', 'Repository cloned'), + { description: repo.displayName } + ) // Why: eagerly upsert so step 2 finds the repo before the IPC event. const state = useAppStore.getState() const existingIdx = state.repos.findIndex((r) => r.id === repo.id) @@ -153,7 +168,7 @@ export function useAddRepoCloneFlow({ setIsCloning(false) } } - }, [cloneUrl, cloneDestination, fetchWorktrees, onGitRepoReady]) + }, [cloneUrl, cloneDestination, fetchWorktrees, onGitRepoReady, sshTargetId]) return { cloneUrl, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d643233df30..0f862bc722d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3043,6 +3043,12 @@ "45b7c26034": "Create project", "85085d74d2": "Creating…" }, + "AddRepoHostSelector": { + "host": "Host", + "local": "Local", + "runtime": "Server", + "ssh": "SSH" + }, "AddRepoNestedImportStep": { "496f68cf8c": "Scanning repositories. Click to stop.", "a32bef9516": "Stop scanning", @@ -3625,6 +3631,7 @@ "5f9ffac036": "Clone a remote Git repository", "7edb8ebe24": "Clone from URL", "a6c20dca96": "Open a project from an SSH target", + "sshCreateUnavailable": "Not available for SSH hosts yet", "3d162cc76f": "Remote project", "fb4fc5380e": "Local project, Git repo, or folder with many repos", "2281fdc8c7": "Browse folder" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 32b08cf7c43..ba2cf5832ec 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3026,6 +3026,12 @@ "45b7c26034": "プロジェクトの作成", "85085d74d2": "作成…" }, + "AddRepoHostSelector": { + "host": "Host", + "local": "Local", + "runtime": "Server", + "ssh": "SSH" + }, "AddRepoNestedImportStep": { "496f68cf8c": "リポジトリをスキャンしています。クリックして停止します。", "a32bef9516": "スキャンを停止する", @@ -3602,6 +3608,7 @@ "c709860596": "新規プロジェクトを作成する", "5f9ffac036": "リモート Git リポジトリのクローンを作成する", "7edb8ebe24": "URLからクローンを作成する", + "sshCreateUnavailable": "Not available for SSH hosts yet", "a6c20dca96": "SSH ターゲットからプロジェクトを開く", "3d162cc76f": "リモートプロジェクト", "fb4fc5380e": "ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6183fb1deb0..ef7aad176db 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3026,6 +3026,12 @@ "45b7c26034": "프로젝트 생성", "85085d74d2": "만드는 중…" }, + "AddRepoHostSelector": { + "host": "Host", + "local": "Local", + "runtime": "Server", + "ssh": "SSH" + }, "AddRepoNestedImportStep": { "496f68cf8c": "저장소를 스캔하는 중입니다. 중지하려면 클릭하세요.", "a32bef9516": "스캔 중지", @@ -3602,6 +3608,7 @@ "c709860596": "새 프로젝트 만들기", "5f9ffac036": "원격 Git 저장소 복제", "7edb8ebe24": "URL에서 복제", + "sshCreateUnavailable": "Not available for SSH hosts yet", "a6c20dca96": "SSH 대상에서 프로젝트 열기", "3d162cc76f": "원격 프로젝트", "fb4fc5380e": "로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e06e46e75a2..a5f32185804 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3026,6 +3026,12 @@ "45b7c26034": "创建项目", "85085d74d2": "创建中…" }, + "AddRepoHostSelector": { + "host": "Host", + "local": "Local", + "runtime": "Server", + "ssh": "SSH" + }, "AddRepoNestedImportStep": { "496f68cf8c": "扫描存储库。单击停止。", "a32bef9516": "停止扫描", @@ -3602,6 +3608,7 @@ "c709860596": "创建新项目", "5f9ffac036": "克隆远程 Git 存储库", "7edb8ebe24": "从 URL 克隆", + "sshCreateUnavailable": "Not available for SSH hosts yet", "a6c20dca96": "从 SSH 目标打开项目", "3d162cc76f": "远程项目", "fb4fc5380e": "本地项目、Git 存储库或包含多个存储库的文件夹",