Make add project host aware

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:34:00 -07:00
co-authored by Orca
parent ce0cc1eeb2
commit 626f2ec0ed
16 changed files with 614 additions and 101 deletions
+15 -1
View File
@@ -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
+1
View File
@@ -52,6 +52,7 @@ import type {
PersistedUIState,
Project,
ProjectHostSetup,
ProjectHostSetupCloneArgs,
ProjectHostSetupCreateArgs,
ProjectHostSetupCreateResult,
ProjectHostSetupDeleteArgs,
@@ -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<AddRepoDialogStep>('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 = (
<AddRepoHostSelector
hosts={hostOptions}
selectedHostId={selectedHostId}
open={hostSelectorOpen}
onOpenChange={setHostSelectorOpen}
onSelectHost={(hostId) => void handleSelectAddProjectHost(hostId)}
/>
)
return (
<Dialog
open={isOpen}
@@ -302,7 +316,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
step={step}
isRuntimeEnvironmentActive={isRuntimeEnvironmentActive}
activeRuntimeEnvironmentId={settings?.activeRuntimeEnvironmentId}
isSshLikely={isSshLikely}
isSshLikely={false}
repoCount={repos.length}
isAdding={isAdding}
addProjectBusyLabel={addProjectBusyLabel}
@@ -329,12 +343,22 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
createKind={createKind}
createError={createError}
isCreating={isCreating}
onBrowse={handleBrowse}
hostSelector={hostSelector}
showRemoteAction={false}
canCreateProject={selectedParsedHost?.kind !== 'ssh'}
onBrowse={
selectedParsedHost?.kind === 'ssh'
? () => void handleOpenRemoteStep(selectedSshTargetId)
: handleBrowse
}
onOpenCloneStep={() => {
setCloneError(null)
setStep('clone')
}}
onOpenCreateStep={() => {
if (selectedParsedHost?.kind === 'ssh') {
return
}
setCreateError(null)
setStep('create')
}}
@@ -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}
@@ -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 (
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.AddRepoHostSelector.host', 'Host')}
</span>
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 min-w-56 justify-between px-3 text-xs font-normal"
>
<span className="min-w-0 truncate">{selectedHost.label}</span>
<ChevronsUpDown className="size-3.5 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[min(340px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0"
>
<Command>
<CommandList>
{hosts.map((host) => {
const selected = host.id === selectedHostId
const disabled = isHostDisabled(host)
return (
<CommandItem
key={host.id}
value={`${host.label} ${host.detail}`}
disabled={disabled}
onSelect={() => {
if (disabled) {
return
}
onSelectHost(host.id)
onOpenChange(false)
}}
className="items-start gap-2 px-3 py-2 text-xs"
>
<Check
className={cn(
'mt-0.5 size-3 text-muted-foreground',
selected ? 'opacity-70' : 'opacity-0'
)}
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{host.label}</span>
<span className="shrink-0 rounded-sm border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{getHostKindLabel(host)}
</span>
</span>
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
{getSidebarHostHealthLabel(host.health)}
{host.detail ? ` - ${host.detail}` : ''}
</span>
</span>
</CommandItem>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
)
}
@@ -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 (
<>
<DialogHeader>
<DialogTitle>{translate("auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d", "Browse server filesystem")}</DialogTitle>
<DialogTitle>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d',
'Browse server filesystem'
)}
</DialogTitle>
<DialogDescription>
{translate("auto.components.sidebar.AddRepoServerStartStep.0f8aba944c", "Navigate to a directory and click Select to choose it.")}</DialogDescription>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.0f8aba944c',
'Navigate to a directory and click Select to choose it.'
)}
</DialogDescription>
</DialogHeader>
<RemoteFileBrowser
runtimeEnvironmentId={runtimeEnvironmentId}
@@ -59,31 +70,59 @@ export function AddRepoServerPathStartStep({
return (
<>
<DialogHeader>
<DialogTitle>{translate("auto.components.sidebar.AddRepoServerStartStep.39bd249b3a", "Add a project")}</DialogTitle>
<DialogTitle>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.39bd249b3a',
'Add a project'
)}
</DialogTitle>
<DialogDescription>
{translate("auto.components.sidebar.AddRepoServerStartStep.8efa930eb5", "Add another project from the selected runtime server.")}</DialogDescription>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.8efa930eb5',
'Add another project from the selected runtime server.'
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-2">
{hostSelector}
<div className="grid grid-cols-3 gap-2">
<AddRepoServerStartAction
icon={FolderOpen}
title={translate("auto.components.sidebar.AddRepoServerStartStep.0adf083af7", "Browse server")}
description={translate("auto.components.sidebar.AddRepoServerStartStep.516187414c", "Existing project or folder")}
title={translate(
'auto.components.sidebar.AddRepoServerStartStep.0adf083af7',
'Browse server'
)}
description={translate(
'auto.components.sidebar.AddRepoServerStartStep.516187414c',
'Existing project or folder'
)}
disabled={disabled}
onClick={() => setBrowsing(true)}
/>
<AddRepoServerStartAction
icon={Globe}
title={translate("auto.components.sidebar.AddRepoServerStartStep.47759c9491", "Clone from URL")}
description={translate("auto.components.sidebar.AddRepoServerStartStep.a2ea37d549", "Remote Git repository")}
title={translate(
'auto.components.sidebar.AddRepoServerStartStep.47759c9491',
'Clone from URL'
)}
description={translate(
'auto.components.sidebar.AddRepoServerStartStep.a2ea37d549',
'Remote Git repository'
)}
disabled={disabled}
onClick={onOpenCloneStep}
/>
<AddRepoServerStartAction
icon={Server}
title={translate("auto.components.sidebar.AddRepoServerStartStep.a81ffa0a99", "Create on server")}
description={translate("auto.components.sidebar.AddRepoServerStartStep.d40d751517", "New repo or folder")}
title={translate(
'auto.components.sidebar.AddRepoServerStartStep.a81ffa0a99',
'Create on server'
)}
description={translate(
'auto.components.sidebar.AddRepoServerStartStep.d40d751517',
'New repo or folder'
)}
disabled={disabled}
onClick={onOpenCreateStep}
/>
@@ -94,7 +133,11 @@ export function AddRepoServerPathStartStep({
<Lightbulb className="size-3.5" />
</span>
<span className="min-w-0">
{translate("auto.components.sidebar.AddRepoServerStartStep.6b9958492a", "Want to import many repos at once? Browse to the parent folder.")}</span>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.6b9958492a',
'Want to import many repos at once? Browse to the parent folder.'
)}
</span>
</div>
<button
@@ -103,7 +146,11 @@ export function AddRepoServerPathStartStep({
disabled={disabled}
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.438493f214", "Or enter a server path manually")}</button>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.438493f214',
'Or enter a server path manually'
)}
</button>
</div>
</>
)
@@ -112,24 +159,38 @@ export function AddRepoServerPathStartStep({
return (
<>
<DialogHeader>
<DialogTitle>{translate("auto.components.sidebar.AddRepoServerStartStep.3d0c035483", "Open server project")}</DialogTitle>
<DialogTitle>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.3d0c035483',
'Open server project'
)}
</DialogTitle>
<DialogDescription>
{translate("auto.components.sidebar.AddRepoServerStartStep.423b5d3d31", "Add a Git repository or folder that already exists on the selected runtime server.")}</DialogDescription>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.423b5d3d31',
'Add a Git repository or folder that already exists on the selected runtime server.'
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-2">
{hostSelector}
<div className="space-y-1">
<label
htmlFor="server-project-path"
className="block text-[11px] font-medium text-muted-foreground"
>
{translate("auto.components.sidebar.AddRepoServerStartStep.867692f505", "Server path")}</label>
{translate('auto.components.sidebar.AddRepoServerStartStep.867692f505', 'Server path')}
</label>
<div className="flex gap-2">
<Input
id="server-project-path"
value={serverPath}
onChange={(event) => 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'
)}
>
<FolderOpen className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate("auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d", "Browse server filesystem")}</TooltipContent>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.ac66a3ed2d',
'Browse server filesystem'
)}
</TooltipContent>
</Tooltip>
</div>
</div>
@@ -160,14 +228,22 @@ export function AddRepoServerPathStartStep({
disabled={!serverPath.trim() || isAddingServerPath}
className="h-10"
>
{translate("auto.components.sidebar.AddRepoServerStartStep.8da4d1a5be", "Add Git Project")}</Button>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.8da4d1a5be',
'Add Git Project'
)}
</Button>
<Button
onClick={() => onAddServerPath('folder')}
disabled={!serverPath.trim() || isAddingServerPath}
variant="outline"
className="h-10"
>
{translate("auto.components.sidebar.AddRepoServerStartStep.e1710bf831", "Open as Folder")}</Button>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.e1710bf831',
'Open as Folder'
)}
</Button>
</div>
{isAddingServerPath && addProjectBusyLabel ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground">
@@ -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")}</button>
{translate(
'auto.components.sidebar.AddRepoServerStartStep.ae990c86a0',
'Back to add options'
)}
</button>
</div>
</>
)
@@ -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')
@@ -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}
<AddRepoPrimaryStartAction
icon={primaryAction.icon}
title={primaryAction.title}
@@ -187,7 +196,7 @@ export function AddRepoLocalStartStep({
icon={action.icon}
title={action.title}
description={action.description}
disabled={isAdding}
disabled={isAdding || Boolean(action.disabled)}
selected={selectedKind === action.kind}
onClick={action.onClick}
onFocus={() => setSelectedKind(action.kind)}
@@ -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 (
<>
<DialogHeader>
<DialogTitle>{translate("auto.components.sidebar.AddRepoSteps.a93ef169b5", "Browse server filesystem")}</DialogTitle>
<DialogTitle>
{translate(
'auto.components.sidebar.AddRepoSteps.a93ef169b5',
'Browse server filesystem'
)}
</DialogTitle>
<DialogDescription>
{translate("auto.components.sidebar.AddRepoSteps.fe8e629fe3", "Navigate to a directory and click Select to choose it.")}</DialogDescription>
{translate(
'auto.components.sidebar.AddRepoSteps.fe8e629fe3',
'Navigate to a directory and click Select to choose it.'
)}
</DialogDescription>
</DialogHeader>
<RemoteFileBrowser
runtimeEnvironmentId={runtimeEnvironmentId}
@@ -313,18 +339,30 @@ export function CloneStep({
return (
<>
<DialogHeader>
<DialogTitle>{translate("auto.components.sidebar.AddRepoSteps.c05f88a31f", "Clone from URL")}</DialogTitle>
<DialogDescription>{translate("auto.components.sidebar.AddRepoSteps.5b2ea674b1", "Enter the Git URL and choose where to clone it.")}</DialogDescription>
<DialogTitle>
{translate('auto.components.sidebar.AddRepoSteps.c05f88a31f', 'Clone from URL')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.sidebar.AddRepoSteps.5b2ea674b1',
'Enter the Git URL and choose where to clone it.'
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-1">
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.AddRepoSteps.3d4acbe693", "Git URL")}</label>
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.AddRepoSteps.3d4acbe693', 'Git URL')}
</label>
<Input
value={cloneUrl}
onChange={(e) => 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({
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.AddRepoSteps.04a4c4e84a", "Clone location")}</label>
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.AddRepoSteps.04a4c4e84a', 'Clone location')}
</label>
<div className="flex gap-2">
<Input
value={cloneDestination}
onChange={(e) => 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')
}
>
<Folder className="size-3.5" />
</Button>
@@ -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')}
</Button>
{/* Why: progress bar lives below the button so it doesn't push the
@@ -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 }
}
@@ -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<typeof useSidebarHostScopeOptions>['hostOptions']
selectedHostId: ExecutionHostId
selectedParsedHost: ReturnType<typeof parseExecutionHostId>
selectedSshTargetId: string | null
hostSelectorOpen: boolean
setHostSelectorOpen: (open: boolean) => void
handleSelectAddProjectHost: (hostId: ExecutionHostId) => Promise<void>
} {
const settings = useAppStore((s) => s.settings)
const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment)
const { hostOptions } = useSidebarHostScopeOptions()
const [selectedAddProjectHostId, setSelectedAddProjectHostId] =
useState<ExecutionHostId>(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<void> => {
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
}
}
@@ -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<unknown>
onGitRepoReady: (repoId: string, source: AddRepoExistingWorkspaceSource) => Promise<void>
@@ -79,10 +81,15 @@ export function useAddRepoCloneFlow({
}, [])
const handlePickDestination = useCallback(async (): Promise<void> => {
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<void> => {
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,
+7
View File
@@ -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"
+7
View File
@@ -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 リポジトリ、または多数のリポジトリを含むフォルダー",
+7
View File
@@ -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 저장소 또는 저장소가 많은 폴더",
+7
View File
@@ -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 存储库或包含多个存储库的文件夹",