Revert "feat(workspace): set project location from the create-worktree host p…" (#14912)

This reverts commit e4e54a17d0.
This commit is contained in:
Jinjing
2026-08-16 10:41:39 -07:00
committed by GitHub
parent 763b1febeb
commit b60df2e3d6
18 changed files with 26 additions and 1243 deletions
@@ -1,68 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ProjectHostSetup } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-types'
import { mergeProjectHostSetupCompatibilityState } from './project-host-compatibility'
const PROJECT_ID = 'github:acme/orca'
function repo(overrides: Partial<Repo> & Pick<Repo, 'id'>): Repo {
return {
path: `/src/${overrides.id}`,
displayName: 'orca',
addedAt: 1,
upstream: { owner: 'acme', repo: 'orca' },
...overrides
} as Repo
}
function pendingSetup(overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup {
return {
id: `${PROJECT_ID}::ssh:devbox`,
projectId: PROJECT_ID,
hostId: 'ssh:devbox',
repoId: '',
path: '',
displayName: 'orca',
setupState: 'not-set-up',
setupMethod: 'pending',
createdAt: 1,
updatedAt: 1,
...overrides
} as ProjectHostSetup
}
describe('mergeProjectHostSetupCompatibilityState', () => {
it('keeps a placeholder for a host with no repo yet', () => {
const merged = mergeProjectHostSetupCompatibilityState(
{ projects: [], projectHostSetups: [pendingSetup()] },
[repo({ id: 'local-repo' })]
)
expect(merged.projectHostSetups.map((setup) => setup.hostId)).toEqual(['local', 'ssh:devbox'])
})
// Setting a location on a host that already had a placeholder projects a ready setup for
// the same project+host; the placeholder must not survive to shadow it.
it('drops a placeholder once a repo covers the same project and host', () => {
const merged = mergeProjectHostSetupCompatibilityState(
{ projects: [], projectHostSetups: [pendingSetup()] },
[repo({ id: 'local-repo' }), repo({ id: 'devbox-repo', executionHostId: 'ssh:devbox' })]
)
expect(merged.projectHostSetups).toHaveLength(2)
expect(merged.projectHostSetups.every((setup) => setup.setupState === 'ready')).toBe(true)
expect(merged.projectHostSetups.map((setup) => setup.id)).toEqual(['local-repo', 'devbox-repo'])
})
it('keeps the placeholder when the repo covers a different host', () => {
const merged = mergeProjectHostSetupCompatibilityState(
{ projects: [], projectHostSetups: [pendingSetup({ hostId: 'ssh:other' })] },
[repo({ id: 'devbox-repo', executionHostId: 'ssh:devbox' })]
)
expect(merged.projectHostSetups.map((setup) => setup.hostId)).toEqual([
'ssh:devbox',
'ssh:other'
])
})
})
@@ -23,10 +23,6 @@ export function isRepoBackedProjectHostSetup(
return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId)
}
function projectHostKey(setup: Pick<ProjectHostSetup, 'projectId' | 'hostId'>): string {
return `${setup.projectId}\u0000${setup.hostId}`
}
export function mergeProjectHostSetupCompatibilityState(
state: Pick<PersistedState, 'projects' | 'projectHostSetups'>,
repos: readonly Repo[]
@@ -39,7 +35,6 @@ export function mergeProjectHostSetupCompatibilityState(
const currentRepoIds = new Set(repos.map((repo) => repo.id))
const projectedProjectIds = new Set(projection.projects.map((project) => project.id))
const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id))
const projectedHosts = new Set(projection.setups.map(projectHostKey))
// Why: legacy/repo-backed setup rows reuse the repo id; keep only independent rows so repo deletion leaves no ghosts.
const independentSetups = (state.projectHostSetups ?? [])
.filter((setup) => {
@@ -53,10 +48,6 @@ export function mergeProjectHostSetupCompatibilityState(
const remappedProjectId = succession.remappedProjectIds.get(setup.projectId)
return remappedProjectId ? { ...setup, projectId: remappedProjectId } : setup
})
// Why: a project resolves to one setup per host. Once a repo projection covers that
// pair, a leftover placeholder is a ghost that shadows the ready row — it sorts first
// and reads back as "not set up". Runs after the remap so renamed rows are caught too.
.filter((setup) => !projectedHosts.has(projectHostKey(setup)))
const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId))
const independentProjects = (state.projects ?? [])
.filter(
@@ -1,238 +0,0 @@
// @vitest-environment happy-dom
import React, { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NewWorkspaceComposerCard from './NewWorkspaceComposerCard'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options'
const storeMocks = vi.hoisted(() => ({
closeModal: vi.fn(),
openModal: vi.fn(),
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: unknown) => unknown) =>
selector({
closeModal: storeMocks.closeModal,
openModal: storeMocks.openModal,
openSettingsPage: storeMocks.openSettingsPage,
openSettingsTarget: storeMocks.openSettingsTarget,
setRuntimeEnvironmentStatus: vi.fn(),
setupProjectExistingFolder: vi.fn(),
setupProjectClone: vi.fn(),
activeModal: 'new-workspace-composer',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn(),
projects: [],
repos: []
}),
{ getState: () => ({}) }
)
}))
vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({
useContextualTour: vi.fn()
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
vi.mock('@/components/agent/AgentCombobox', () => ({
default: () => <button type="button">Agent picker</button>
}))
vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({
AddRemoteHostDialog: () => null
}))
vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({
default: () => null
}))
vi.mock('@/components/new-workspace/SmartWorkspaceNameField', () => ({
default: () => <input aria-label="workspace name" />
}))
vi.mock('@/components/new-workspace/ProjectCombobox', () => ({
default: () => <div data-testid="project-combobox" />
}))
vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({
SetProjectLocationDialog: ({
option,
projectName
}: {
option: { label: string } | null
projectName: string
}) =>
option ? (
<div
data-testid="set-project-location-dialog"
data-host={option.label}
data-project={projectName}
/>
) : null
}))
const projectOptions: NewWorkspaceProjectOption[] = [
{
kind: 'project-group',
id: 'project-group:platform',
projectGroupId: 'platform',
displayName: 'Platform',
badgeColor: 'var(--muted-foreground)',
detail: '/workspace/platform',
parentPath: '/workspace/platform',
connectionId: null
}
]
const hostOptions: ProjectHostSetupOption[] = [
{
kind: 'ready',
id: 'setup-local',
projectId: 'project-group:platform',
hostId: 'local',
repoId: 'repo-a',
label: 'Local Mac',
detail: 'Orca',
path: '/Users/alice/orca'
},
{
kind: 'needs-setup',
id: 'needs-setup:ssh:devbox',
projectId: 'project-group:platform',
hostId: 'ssh:devbox',
label: 'Devbox',
detail: 'Project location not set',
isAvailable: true,
attention: false,
canSetLocation: true
}
]
function renderCard(
overrides: Partial<React.ComponentProps<typeof NewWorkspaceComposerCard>> = {}
): HTMLDivElement {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => {
root.render(
<NewWorkspaceComposerCard
quickAgent={null}
onQuickAgentChange={() => {}}
eligibleRepos={[]}
repoId="repo-a"
projectOptions={projectOptions}
selectedProjectId="project-group:platform"
selectedRepoIsGit
onRepoChange={() => {}}
onProjectChange={() => {}}
primaryActionLabel="Create workspace"
name=""
onNameValueChange={() => {}}
onSmartGitHubItemSelect={() => {}}
onSmartGitLabItemSelect={() => {}}
onSmartBranchSelect={() => {}}
onSmartLinearIssueSelect={() => {}}
smartNameSelection={null}
onClearSmartNameSelection={() => {}}
canReuseSelectedBranch={false}
reuseSelectedBranch={false}
onReuseSelectedBranchChange={() => {}}
forkPushWarning={null}
detectedAgentIds={null}
onOpenAgentSettings={() => {}}
advancedOpen={false}
onToggleAdvanced={() => {}}
createDisabled={false}
projectError={null}
creating={false}
onCreate={() => {}}
note=""
onNoteChange={() => {}}
setupConfig={null}
requiresExplicitSetupChoice={false}
setupDecision={null}
onSetupDecisionChange={() => {}}
setupAgentStartupPolicy="start-immediately"
onSetupAgentStartupPolicyChange={() => {}}
shouldWaitForSetupCheck={false}
resolvedSetupDecision={null}
createError={null}
selectedRepoConnectionId={null}
selectedRepoSshStatus={null}
selectedRepoRequiresConnection={false}
selectedRepoConnectInProgress={false}
onConnectSelectedRepo={async () => {}}
canUseSparseCheckout={false}
sparsePresets={[]}
sparseSelectedPresetId={null}
onSparseSelectPreset={() => {}}
branchNameOverride={undefined}
onBranchNameOverrideChange={() => {}}
branchesEnabled={false}
setupControlsEnabled={false}
sparseControlsEnabled={false}
projectHostSetupOptions={hostOptions}
selectedProjectHostSetupId="setup-local"
{...overrides}
/>
)
})
return container
}
describe('NewWorkspaceComposerCard set location', () => {
let container: HTMLDivElement | null = null
beforeEach(() => {
storeMocks.closeModal.mockReset()
storeMocks.openModal.mockReset()
storeMocks.openSettingsPage.mockReset()
})
afterEach(() => {
container?.remove()
container = null
})
it('opens set-location over the composer without leaving the create dialog', () => {
const nestedOpenChanges: boolean[] = []
container = renderCard({
onNestedDialogOpenChange: (open) => nestedOpenChanges.push(open)
})
act(() => {
container?.querySelector<HTMLElement>('div[data-run-target-combobox-root="true"]')?.click()
})
const setLocation = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent?.includes('Set project location')
)
expect(setLocation).toBeTruthy()
act(() => setLocation?.click())
const dialog = document.body.querySelector('[data-testid="set-project-location-dialog"]')
expect(dialog?.getAttribute('data-host')).toBe('Devbox')
expect(dialog?.getAttribute('data-project')).toBe('Platform')
expect(nestedOpenChanges).toEqual([true])
expect(storeMocks.closeModal).not.toHaveBeenCalled()
expect(storeMocks.openModal).not.toHaveBeenCalled()
expect(storeMocks.openSettingsPage).not.toHaveBeenCalled()
})
})
@@ -31,9 +31,7 @@ vi.mock('@/store', () => ({
setRuntimeEnvironmentStatus: storeMocks.setRuntimeEnvironmentStatus,
activeModal: 'none',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn(),
projects: [],
repos: []
updateSettings: vi.fn()
}),
{
getState: () => ({
@@ -64,10 +62,6 @@ vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({
mode ? <div data-testid="add-remote-host-dialog" data-mode={mode} /> : null
}))
vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({
SetProjectLocationDialog: () => null
}))
vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({
default: () => <div data-testid="sparse-select" />
}))
@@ -161,8 +155,7 @@ const devboxNeedsSetupHostOption: ProjectHostSetupOption = {
label: 'Devbox',
detail: 'Project location not set',
isAvailable: true,
attention: false,
canSetLocation: true
attention: false
}
const disconnectedDevboxNeedsSetupHostOption: ProjectHostSetupOption = {
@@ -174,7 +167,6 @@ const disconnectedDevboxNeedsSetupHostOption: ProjectHostSetupOption = {
detail: 'Connect this host to set up projects',
isAvailable: false,
attention: false,
canSetLocation: false,
connectAction: { kind: 'ssh', targetId: 'devbox' }
}
@@ -187,7 +179,6 @@ const disconnectedBastionNeedsSetupHostOption: ProjectHostSetupOption = {
detail: 'Connect this host to set up projects',
isAvailable: false,
attention: false,
canSetLocation: false,
connectAction: { kind: 'ssh', targetId: 'bastion' }
}
@@ -610,8 +601,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
openRunTargetPicker(current.container)
const devboxItem = findRunTargetItem('Devbox')
expect(devboxItem?.textContent).not.toContain('Project location not set')
expect(devboxItem?.textContent).toContain('Set project location')
expect(devboxItem?.textContent).toContain('Project location not set')
// Not-connected rows stay highlightable (never `disabled`) so they hover like
// the other rows; they're quieted visually instead.
expect(devboxItem?.hasAttribute('data-disabled')).toBe(false)
@@ -48,7 +48,6 @@ import SmartWorkspaceNameField, {
import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results'
import ProjectCombobox from '@/components/new-workspace/ProjectCombobox'
import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox'
import { SetProjectLocationDialog } from '@/components/new-workspace/SetProjectLocationDialog'
import {
AddRemoteHostDialog,
type AddRemoteHostMode
@@ -163,8 +162,6 @@ type NewWorkspaceComposerCardProps = {
sparseControlsEnabled?: boolean
/** When set, "Add project" opens a host-provided flow instead of swapping the store's active modal. */
onAddProjectOverride?: () => void
/** Fires as the nested Set-project-location dialog opens and closes, so the host can stand down its Escape/submit handling. */
onNestedDialogOpenChange?: (open: boolean) => void
}
const SSH_STATUS_LABELS: Partial<Record<SshConnectionStatus, string>> = {
@@ -374,8 +371,7 @@ export default function NewWorkspaceComposerCard({
sparseSelectedPresetId,
onSparseSelectPreset,
sparseControlsEnabled = true,
onAddProjectOverride,
onNestedDialogOpenChange
onAddProjectOverride
}: NewWorkspaceComposerCardProps): React.JSX.Element {
// Why: subscribe (form uses translate() directly) so an open create dialog repaints when the UI language changes.
useTranslation()
@@ -503,38 +499,6 @@ export default function NewWorkspaceComposerCard({
const handleAddRemoteServer = React.useCallback((): void => {
setAddRemoteHostMode('server')
}, [])
const [setLocationOption, setSetLocationOption] =
React.useState<NeedsSetupProjectHostOption | null>(null)
const handleSetLocation = React.useCallback(
(option: NeedsSetupProjectHostOption): void => {
setSetLocationOption(option)
onNestedDialogOpenChange?.(true)
},
[onNestedDialogOpenChange]
)
const handleSetLocationClose = React.useCallback((): void => {
setSetLocationOption(null)
onNestedDialogOpenChange?.(false)
}, [onNestedDialogOpenChange])
const handleSetLocationReady = React.useCallback(
(setupId: string): void => {
handleSetLocationClose()
onProjectHostSetupChange?.(setupId)
},
[handleSetLocationClose, onProjectHostSetupChange]
)
const projects = useAppStore((state) => state.projects)
const repos = useAppStore((state) => state.repos)
// Prefill "Clone from URL" with the project's own remote so the common case is one click.
const defaultCloneUrl = React.useMemo(() => {
const sourceRepoIds =
projects.find((candidate) => candidate.id === selectedProjectId)?.sourceRepoIds ?? []
return (
sourceRepoIds
.map((sourceId) => repos.find((repo) => repo.id === sourceId)?.gitRemoteIdentity?.remoteUrl)
.find((url): url is string => Boolean(url)) ?? ''
)
}, [projects, repos, selectedProjectId])
const handleConnectRunTargetHost = React.useCallback(
async (option: NeedsSetupProjectHostOption): Promise<void> => {
const action = option.connectAction
@@ -740,7 +704,6 @@ export default function NewWorkspaceComposerCard({
onAddSshHost={handleAddSshHost}
onAddRemoteServer={handleAddRemoteServer}
onConnectHost={handleConnectRunTargetHost}
onSetLocation={handleSetLocation}
/>
{ephemeralVmRecipeError ? (
<p className="whitespace-pre-line text-[11px] text-destructive">
@@ -1268,14 +1231,6 @@ export default function NewWorkspaceComposerCard({
the in-progress workspace form is preserved; on success the new host flows back into
the run-target picker via the store. */}
<AddRemoteHostDialog mode={addRemoteHostMode} onOpenChange={setAddRemoteHostMode} />
<SetProjectLocationDialog
option={setLocationOption}
projectName={selectedProjectName}
projectKind={selectedRepoIsGit ? 'git' : 'folder'}
defaultCloneUrl={defaultCloneUrl}
onClose={handleSetLocationClose}
onReady={handleSetLocationReady}
/>
</div>
)
}
@@ -197,7 +197,6 @@ function QuickTabBody({
// outcomes still navigate away and tear the whole modal down.)
const [addProjectOpen, setAddProjectOpen] = useState(false)
const [addProjectMounted, setAddProjectMounted] = useState(false)
const [setLocationOpen, setSetLocationOpen] = useState(false)
const handleOpenAddProject = useCallback((): void => {
setAddProjectMounted(true)
setAddProjectOpen(true)
@@ -239,10 +238,10 @@ function QuickTabBody({
: translate('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace')
// Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page).
const nestedDialogOpen = agentSettingsOpen || addProjectOpen || setLocationOpen
const nestedDialogOpen = agentSettingsOpen || addProjectOpen
useEffect(() => {
if (!active || nestedDialogOpen) {
// Why: while a nested dialog (Add Project / Agents / Set location) is layered on top,
// Why: while a nested dialog (Add Project / Agents) is layered on top,
// this capture-phase handler must not steal its Escape (which should
// close only the nested dialog) or fire composer submit underneath it.
return
@@ -325,7 +324,6 @@ function QuickTabBody({
onOpenAgentSettings={() => setAgentSettingsOpen(true)}
onCreate={() => void handleCreate()}
onAddProjectOverride={handleOpenAddProject}
onNestedDialogOpenChange={setSetLocationOpen}
/>
<AgentSettingsDialog open={agentSettingsOpen} onOpenChange={setAgentSettingsOpen} />
{addProjectMounted ? (
@@ -12,8 +12,7 @@ import {
ConnectHostButton,
HostRowIcon,
NeedsSetupHostIcon,
RunTargetRow,
SetLocationButton
RunTargetRow
} from './RunTargetComboboxRow'
import {
buildRunTargetRows,
@@ -35,7 +34,6 @@ type RunTargetComboboxProps = {
onAddRemoteServer?: () => void
onAddSshHost?: () => void
onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise<void> | void
onSetLocation?: (option: NeedsSetupProjectHostOption) => void
}
const ROOT_ATTRIBUTE = 'data-run-target-combobox-root'
@@ -58,8 +56,7 @@ export default function RunTargetCombobox({
onRecipeChange,
onAddRemoteServer,
onAddSshHost,
onConnectHost,
onSetLocation
onConnectHost
}: RunTargetComboboxProps): React.JSX.Element {
const [submenu, setSubmenu] = useState<'recipes' | 'add-host' | null>(null)
// Track in-flight connects per host so one stalling connect never blocks the others.
@@ -137,19 +134,6 @@ export default function RunTargetCombobox({
[connectingHostIds, onConnectHost]
)
// Why: reached from the row body, its inline button, and Enter — keep one path
// so all three close the picker before handing off to the nested dialog.
const setLocation = useCallback(
(option: NeedsSetupProjectHostOption): void => {
if (!option.canSetLocation || !onSetLocation) {
return
}
close()
onSetLocation(option)
},
[close, onSetLocation]
)
/** Commits a row, or opens its submenu when the row is a submenu row. */
const activate = useCallback(
(key: string | null): void => {
@@ -162,13 +146,12 @@ export default function RunTargetCombobox({
return
}
if (row.kind === 'needs-setup') {
// Not ready: setting the location is the only way forward from the row itself.
setLocation(row.option)
// Not ready: selecting is a no-op, the Connect action is the way forward.
return
}
setSubmenu(row.kind === 'recipes' ? 'recipes' : 'add-host')
},
[rows, selectHost, setLocation]
[rows, selectHost]
)
const handleKeyDown = useCallback(
@@ -308,7 +291,6 @@ export default function RunTargetCombobox({
if (row.kind === 'needs-setup') {
const connecting = connectingHostIds.has(row.option.hostId)
const hasConnect = Boolean(row.option.connectAction && onConnectHost)
const hasSetLocation = Boolean(row.option.canSetLocation && onSetLocation)
return (
<RunTargetRow
key={row.key}
@@ -320,9 +302,10 @@ export default function RunTargetCombobox({
/>
}
label={row.option.label}
// Why: Connect / Set project location already say the next
// step, so the detail line would only repeat that.
detail={hasConnect || hasSetLocation ? '' : row.option.detail}
// Why: a Connect button on the row already says the host
// isn't connected, so its detail line only repeats that.
// Rows without the action still need theirs to explain why.
detail={hasConnect ? '' : row.option.detail}
armed={isArmed}
current={false}
dimmed
@@ -331,18 +314,13 @@ export default function RunTargetCombobox({
arm(row.key)
setSubmenu(null)
}}
onCommit={() => setLocation(row.option)}
onCommit={() => {}}
trailing={
hasConnect ? (
row.option.connectAction && onConnectHost ? (
<ConnectHostButton
connecting={connecting}
onConnect={() => void connectHost(row.option)}
/>
) : hasSetLocation ? (
<SetLocationButton
hostLabel={row.option.label}
onSetLocation={() => setLocation(row.option)}
/>
) : undefined
}
/>
@@ -1,7 +1,6 @@
import React from 'react'
import { AlertTriangle, ChevronRight, LoaderCircle, Monitor, Server } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import { ProjectOptionDetail } from './ProjectComboboxRow'
@@ -174,52 +173,3 @@ export function ConnectHostButton({
</Button>
)
}
/** Inline Set project location action on a host that still needs a project path. */
export function SetLocationButton({
hostLabel,
onSetLocation
}: {
hostLabel: string
onSetLocation: () => void
}): React.JSX.Element {
const label = translate(
'auto.components.NewWorkspaceComposerCard.setLocation',
'Set project location'
)
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="xs"
className="ml-auto shrink-0 self-center"
aria-label={translate(
'auto.components.NewWorkspaceComposerCard.setLocationOnHost',
'Set project location on {{host}}',
{ host: hostLabel }
)}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onSetLocation()
}}
>
{label}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate(
'auto.components.NewWorkspaceComposerCard.setLocationTooltip',
'Choose a folder or clone this project onto {{host}}.',
{ host: hostLabel }
)}
</TooltipContent>
</Tooltip>
)
}
@@ -1,177 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ComponentProps } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NeedsSetupProjectHostOption } from '@/lib/project-host-setup-options'
const storeMocks = vi.hoisted(() => ({
setupProjectExistingFolder: vi.fn(),
setupProjectClone: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) => selector(storeMocks)
}))
vi.mock('@/components/sidebar/RemoteFileBrowser', () => ({
RemoteFileBrowser: ({
targetId,
onSelect,
onCancel
}: {
targetId?: string
onSelect: (path: string) => void
onCancel: () => void
}) => (
<div data-testid="remote-file-browser" data-target={targetId ?? ''}>
<button type="button" onClick={() => onSelect('/remote/orca')}>
Select remote folder
</button>
<button type="button" onClick={onCancel}>
Cancel browse
</button>
</div>
)
}))
import { SetProjectLocationDialog } from './SetProjectLocationDialog'
const option: NeedsSetupProjectHostOption = {
kind: 'needs-setup',
id: 'needs-setup:ssh:openclaw',
projectId: 'project-orca',
hostId: 'ssh:openclaw',
label: 'openclaw',
detail: 'Project location not set',
isAvailable: true,
attention: false,
canSetLocation: true
}
function renderDialog(
overrides: Partial<ComponentProps<typeof SetProjectLocationDialog>> = {}
): ReturnType<typeof userEvent.setup> {
const user = userEvent.setup()
render(
<SetProjectLocationDialog
option={option}
projectName="orca"
projectKind="git"
defaultCloneUrl="git@github.com:stablyai/orca.git"
onClose={vi.fn()}
onReady={vi.fn()}
{...overrides}
/>
)
return user
}
beforeEach(() => {
storeMocks.setupProjectExistingFolder.mockReset()
storeMocks.setupProjectClone.mockReset()
storeMocks.setupProjectExistingFolder.mockResolvedValue({
setup: { id: 'setup-openclaw' }
})
storeMocks.setupProjectClone.mockResolvedValue({
setup: { id: 'setup-openclaw-clone' }
})
})
afterEach(() => {
cleanup()
})
describe('SetProjectLocationDialog', () => {
it('keeps the parent caller in charge of close and saves an existing folder', async () => {
const onClose = vi.fn()
const onReady = vi.fn()
const user = renderDialog({ onClose, onReady })
expect(screen.getByTestId('set-project-location-dialog')).toBeTruthy()
expect(screen.getByText('Set project location')).toBeTruthy()
expect(screen.getByText('Choose where orca lives on openclaw.')).toBeTruthy()
await user.click(screen.getByRole('button', { name: /Browse folder/ }))
await user.click(screen.getByRole('button', { name: 'Browse host filesystem' }))
await user.click(screen.getByRole('button', { name: 'Select remote folder' }))
await user.click(screen.getByRole('button', { name: 'Set location' }))
expect(storeMocks.setupProjectExistingFolder).toHaveBeenCalledWith({
projectId: 'project-orca',
hostId: 'ssh:openclaw',
path: '/remote/orca',
kind: 'git',
displayName: 'orca'
})
expect(onReady).toHaveBeenCalledWith('setup-openclaw')
expect(onClose).not.toHaveBeenCalled()
})
it('clones onto the selected host', async () => {
const onReady = vi.fn()
const user = renderDialog({ onReady })
await user.click(screen.getByRole('button', { name: /Clone from URL/ }))
await user.click(screen.getByRole('button', { name: 'Browse host filesystem' }))
await user.click(screen.getByRole('button', { name: 'Select remote folder' }))
await user.click(screen.getByRole('button', { name: 'Clone' }))
expect(storeMocks.setupProjectClone).toHaveBeenCalledWith({
projectId: 'project-orca',
hostId: 'ssh:openclaw',
url: 'git@github.com:stablyai/orca.git',
destination: '/remote/orca',
displayName: 'orca'
})
expect(onReady).toHaveBeenCalledWith('setup-openclaw-clone')
})
it('hides clone for folder projects', () => {
renderDialog({ projectKind: 'folder' })
expect(screen.queryByRole('button', { name: /Clone from URL/ })).toBeNull()
})
it('does not render when no host is selected', () => {
render(
<SetProjectLocationDialog
option={null}
projectName="orca"
projectKind="git"
defaultCloneUrl=""
onClose={vi.fn()}
onReady={vi.fn()}
/>
)
expect(screen.queryByTestId('set-project-location-dialog')).toBeNull()
})
it('backs out of the host browser on Escape instead of discarding the form', async () => {
const onClose = vi.fn()
const user = renderDialog({ onClose })
await user.click(screen.getByRole('button', { name: /Browse folder/ }))
await user.click(screen.getByRole('button', { name: 'Browse host filesystem' }))
expect(screen.getByTestId('remote-file-browser')).toBeTruthy()
await user.keyboard('{Escape}')
expect(onClose).not.toHaveBeenCalled()
expect(screen.queryByTestId('remote-file-browser')).toBeNull()
expect(screen.getByRole('button', { name: 'Set location' })).toBeTruthy()
// A second Escape, now back on the form, dismisses the dialog as usual.
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('notifies the parent when dismissed', async () => {
const onClose = vi.fn()
renderDialog({ onClose })
await act(async () => {
document.querySelector<HTMLButtonElement>('[data-slot="dialog-close"]')?.click()
})
expect(onClose).toHaveBeenCalled()
})
})
@@ -1,278 +0,0 @@
import { useEffect, useRef, useState, type RefObject } from 'react'
import { Download, FolderOpen } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { CreateProjectParentBrowser } from '@/components/sidebar/CreateProjectLocationField'
import type { NeedsSetupProjectHostOption } from '@/lib/project-host-setup-options'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import type { RepoKind } from '../../../../shared/repo-types'
import { pickLocalProjectLocationFolder } from './pick-local-project-folder'
import { CloneForm, ExistingFolderForm, LocationActionButton } from './SetProjectLocationForms'
type DialogView = 'choose' | 'existing' | 'clone' | 'browse'
type BrowseField = 'existing' | 'clone'
type SetProjectLocationDialogProps = {
option: NeedsSetupProjectHostOption | null
projectName: string
projectKind: RepoKind
defaultCloneUrl: string
onClose: () => void
onReady: (setupId: string) => void
}
export function SetProjectLocationDialog({
option,
projectName,
projectKind,
defaultCloneUrl,
onClose,
onReady
}: SetProjectLocationDialogProps): React.JSX.Element {
const open = option !== null
// Why: keep the last option rendered through the close animation, so the body
// doesn't blank out as the dialog slides away.
const [renderOption, setRenderOption] = useState(option)
if (option !== null && option !== renderOption) {
setRenderOption(option)
}
const activeOption = option ?? renderOption
// Why: Radix dismisses on Escape from a document-capture listener, so the host
// browser can never intercept it itself. The body parks a back-out here so
// Escape steps out of the browser instead of discarding the half-filled form.
const exitHostBrowser = useRef<(() => boolean) | null>(null)
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) {
onClose()
}
}}
>
<DialogContent
data-testid="set-project-location-dialog"
className="sm:max-w-lg"
onEscapeKeyDown={(event) => {
if (exitHostBrowser.current?.()) {
// Radix only skips onDismiss when the escape was defaultPrevented.
event.preventDefault()
}
}}
>
{activeOption ? (
<SetProjectLocationDialogBody
key={activeOption.id}
option={activeOption}
projectName={projectName}
projectKind={projectKind}
defaultCloneUrl={defaultCloneUrl}
exitHostBrowser={exitHostBrowser}
onReady={onReady}
/>
) : null}
</DialogContent>
</Dialog>
)
}
function SetProjectLocationDialogBody({
option,
projectName,
projectKind,
defaultCloneUrl,
exitHostBrowser,
onReady
}: {
option: NeedsSetupProjectHostOption
projectName: string
projectKind: RepoKind
defaultCloneUrl: string
exitHostBrowser: RefObject<(() => boolean) | null>
onReady: (setupId: string) => void
}): React.JSX.Element {
const setupProjectExistingFolder = useAppStore((state) => state.setupProjectExistingFolder)
const setupProjectClone = useAppStore((state) => state.setupProjectClone)
const [view, setView] = useState<DialogView>('choose')
const [browseField, setBrowseField] = useState<BrowseField>('existing')
const [setupPath, setSetupPath] = useState('')
const [setupKind, setSetupKind] = useState<RepoKind>(projectKind)
const [cloneUrl, setCloneUrl] = useState(defaultCloneUrl)
const [cloneDestination, setCloneDestination] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const parsedHost = parseExecutionHostId(option.hostId)
// Remote hosts browse in-dialog; the local host gets the native folder picker.
const remoteHost =
parsedHost?.kind === 'ssh' || parsedHost?.kind === 'runtime' ? parsedHost : null
const canClone = projectKind === 'git'
// Both views browse for a path; this is the field each one writes back to.
const pathFields: Record<BrowseField, { value: string; set: (path: string) => void }> = {
existing: { value: setupPath, set: setSetupPath },
clone: { value: cloneDestination, set: setCloneDestination }
}
const browsing = view === 'browse' && remoteHost !== null
useEffect(() => {
exitHostBrowser.current = browsing
? () => {
setView(browseField)
return true
}
: null
return () => {
exitHostBrowser.current = null
}
}, [browseField, browsing, exitHostBrowser])
const openHostBrowser = (field: BrowseField): void => {
if (!remoteHost) {
void pickLocalProjectLocationFolder(pathFields[field].set)
return
}
setBrowseField(field)
setView('browse')
}
const handleExistingSubmit = async (): Promise<void> => {
if (!setupPath.trim()) {
return
}
setIsSubmitting(true)
try {
const result = await setupProjectExistingFolder({
projectId: option.projectId,
hostId: option.hostId,
path: setupPath.trim(),
kind: setupKind,
displayName: projectName
})
if (result) {
onReady(result.setup.id)
}
} finally {
setIsSubmitting(false)
}
}
const handleCloneSubmit = async (): Promise<void> => {
if (!cloneUrl.trim() || !cloneDestination.trim()) {
return
}
setIsSubmitting(true)
try {
const result = await setupProjectClone({
projectId: option.projectId,
hostId: option.hostId,
url: cloneUrl.trim(),
destination: cloneDestination.trim(),
displayName: projectName
})
if (result) {
onReady(result.setup.id)
}
} finally {
setIsSubmitting(false)
}
}
if (browsing && remoteHost) {
return (
<CreateProjectParentBrowser
sshTargetId={remoteHost.kind === 'ssh' ? remoteHost.targetId : null}
runtimeEnvironmentId={remoteHost.kind === 'runtime' ? remoteHost.environmentId : null}
createParent={pathFields[browseField].value}
onParentChange={pathFields[browseField].set}
onClose={() => setView(browseField)}
/>
)
}
return (
<>
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.new.workspace.SetProjectLocationDialog.title',
'Set project location'
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.new.workspace.SetProjectLocationDialog.description',
'Choose where {{project}} lives on {{host}}.',
{ project: projectName, host: option.label }
)}
</DialogDescription>
</DialogHeader>
{view === 'choose' ? (
<div className="space-y-2">
<LocationActionButton
icon={FolderOpen}
title={translate(
'auto.components.new.workspace.SetProjectLocationDialog.browseFolder',
'Browse folder'
)}
description={translate(
'auto.components.new.workspace.SetProjectLocationDialog.browseFolderHelp',
'Use an existing checkout or folder on this host.'
)}
onClick={() => {
setView('existing')
// Local hosts get the native picker straight away — one click instead of two.
if (!remoteHost && !setupPath) {
void pickLocalProjectLocationFolder(setSetupPath)
}
}}
/>
{canClone ? (
<LocationActionButton
icon={Download}
title={translate(
'auto.components.new.workspace.SetProjectLocationDialog.cloneFromUrl',
'Clone from URL'
)}
description={translate(
'auto.components.new.workspace.SetProjectLocationDialog.cloneFromUrlHelp',
'Clone this repository onto {{host}}.',
{ host: option.label }
)}
onClick={() => setView('clone')}
/>
) : null}
</div>
) : null}
{view === 'existing' ? (
<ExistingFolderForm
setupPath={setupPath}
setupKind={setupKind}
isSubmitting={isSubmitting}
onBack={() => setView('choose')}
onPathChange={setSetupPath}
onKindChange={setSetupKind}
onBrowse={() => openHostBrowser('existing')}
onSubmit={() => void handleExistingSubmit()}
/>
) : null}
{view === 'clone' ? (
<CloneForm
cloneUrl={cloneUrl}
cloneDestination={cloneDestination}
isSubmitting={isSubmitting}
onBack={() => setView('choose')}
onCloneUrlChange={setCloneUrl}
onCloneDestinationChange={setCloneDestination}
onBrowse={() => openHostBrowser('clone')}
onSubmit={() => void handleCloneSubmit()}
/>
) : null}
</>
)
}
@@ -1,224 +0,0 @@
import { ArrowLeft, FolderOpen, LoaderCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { RepoKind } from '../../../../shared/repo-types'
export function ExistingFolderForm({
setupPath,
setupKind,
isSubmitting,
onBack,
onPathChange,
onKindChange,
onBrowse,
onSubmit
}: {
setupPath: string
setupKind: RepoKind
isSubmitting: boolean
onBack: () => void
onPathChange: (value: string) => void
onKindChange: (value: RepoKind) => void
onBrowse: () => void
onSubmit: () => void
}): React.JSX.Element {
return (
<div className="space-y-3">
<StepBackButton
onBack={onBack}
label={translate(
'auto.components.settings.RepositoryPane.existingFolder',
'Existing folder'
)}
/>
<div className="flex gap-2">
<Input
value={setupPath}
onChange={(event) => onPathChange(event.target.value)}
placeholder={translate(
'auto.components.settings.RepositoryPane.setupExistingFolderPathPlaceholder',
'/path/to/project/on/host'
)}
className="h-9 min-w-0 flex-1 font-mono text-sm"
spellCheck={false}
/>
<Button
type="button"
variant="outline"
size="icon"
className="size-9 shrink-0"
onClick={onBrowse}
aria-label={translate(
'auto.components.sidebar.CreateProjectLocationField.f520f83a97',
'Browse host filesystem'
)}
>
<FolderOpen className="size-4" />
</Button>
</div>
<Select value={setupKind} onValueChange={(value) => onKindChange(value as RepoKind)}>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="git">
{translate('auto.components.settings.RepositoryPane.setupKindGit', 'Git repo')}
</SelectItem>
<SelectItem value="folder">
{translate('auto.components.settings.RepositoryPane.setupKindFolder', 'Folder')}
</SelectItem>
</SelectContent>
</Select>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={!setupPath.trim() || isSubmitting}
onClick={onSubmit}
>
{isSubmitting ? <LoaderCircle className="size-3.5 animate-spin" /> : null}
{isSubmitting
? translate('auto.components.settings.RepositoryPane.settingUpHost', 'Importing...')
: translate(
'auto.components.new.workspace.SetProjectLocationDialog.saveLocation',
'Set location'
)}
</Button>
</div>
</div>
)
}
export function CloneForm({
cloneUrl,
cloneDestination,
isSubmitting,
onBack,
onCloneUrlChange,
onCloneDestinationChange,
onBrowse,
onSubmit
}: {
cloneUrl: string
cloneDestination: string
isSubmitting: boolean
onBack: () => void
onCloneUrlChange: (value: string) => void
onCloneDestinationChange: (value: string) => void
onBrowse: () => void
onSubmit: () => void
}): React.JSX.Element {
return (
<div className="space-y-3">
<StepBackButton
onBack={onBack}
label={translate('auto.components.settings.RepositoryPane.cloneFromUrl', 'Clone from URL')}
/>
<Input
value={cloneUrl}
onChange={(event) => onCloneUrlChange(event.target.value)}
placeholder={translate(
'auto.components.settings.RepositoryPane.cloneUrlPlaceholder',
'Repository URL'
)}
className="h-9 min-w-0"
spellCheck={false}
/>
<div className="flex gap-2">
<Input
value={cloneDestination}
onChange={(event) => onCloneDestinationChange(event.target.value)}
placeholder={translate(
'auto.components.settings.RepositoryPane.cloneDestinationPlaceholder',
'/destination/on/host'
)}
className="h-9 min-w-0 flex-1 font-mono text-sm"
spellCheck={false}
/>
<Button
type="button"
variant="outline"
size="icon"
className="size-9 shrink-0"
onClick={onBrowse}
aria-label={translate(
'auto.components.sidebar.CreateProjectLocationField.f520f83a97',
'Browse host filesystem'
)}
>
<FolderOpen className="size-4" />
</Button>
</div>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={!cloneUrl.trim() || !cloneDestination.trim() || isSubmitting}
onClick={onSubmit}
>
{isSubmitting ? <LoaderCircle className="size-3.5 animate-spin" /> : null}
{isSubmitting
? translate('auto.components.settings.RepositoryPane.cloningHost', 'Cloning...')
: translate('auto.components.settings.RepositoryPane.cloneHost', 'Clone')}
</Button>
</div>
</div>
)
}
export function LocationActionButton({
icon: Icon,
title,
description,
onClick
}: {
icon: typeof FolderOpen
title: string
description: string
onClick: () => void
}): React.JSX.Element {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex min-h-[3.25rem] w-full items-center gap-3 rounded-md border border-border/70 bg-muted/20 px-3 py-2.5 text-left transition-colors',
'hover:bg-accent focus-visible:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none'
)}
>
<span className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium leading-5">{title}</span>
<span className="mt-0.5 block text-xs font-normal leading-4 text-muted-foreground">
{description}
</span>
</span>
</button>
)
}
function StepBackButton({
onBack,
label
}: {
onBack: () => void
label: string
}): React.JSX.Element {
return (
<Button type="button" variant="ghost" size="sm" className="-ml-2 gap-2" onClick={onBack}>
<ArrowLeft className="size-4" />
{label}
</Button>
)
}
@@ -1,14 +0,0 @@
import { toast } from 'sonner'
export async function pickLocalProjectLocationFolder(
onPicked: (path: string) => void
): Promise<void> {
try {
const path = await window.api.repos.pickFolder()
if (path) {
onPicked(path)
}
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
}
}
@@ -55,7 +55,7 @@ describe('useComposerState host retarget', () => {
'const handleProjectHostSetupChange',
'const handleProjectChange'
)
expect(switchSection).toContain('setSelectedProjectHostSetupOverrideId(target.id)')
expect(switchSection).toContain('setSelectedProjectHostSetupOverrideId(option.id)')
expect(switchSection).toContain('preserveStartFrom: true')
expect(switchSection).toContain('forceResetStartFrom: true')
})
+3 -12
View File
@@ -2796,21 +2796,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const handleProjectHostSetupChange = useCallback(
(setupId: string): void => {
const option = projectHostSetupOptions.find((candidate) => candidate.id === setupId)
const target =
option?.kind === 'ready'
? option
: // Why: a just-created setup lands in the store before the memoized picker options refresh.
useAppStore
.getState()
.projectHostSetups.find(
(candidate) => candidate.id === setupId && candidate.setupState === 'ready'
)
if (!target) {
if (!option || option.kind !== 'ready') {
return
}
// Why: switching run host for the same project must not erase the task/PR source the user is starting from.
setSelectedProjectHostSetupOverrideId(target.id)
handleRepoChange(target.repoId, {
setSelectedProjectHostSetupOverrideId(option.id)
handleRepoChange(option.repoId, {
preserveStartFrom: true,
forceResetStartFrom: true
})
-12
View File
@@ -1489,9 +1489,6 @@
"connectTimedOut": "Connection timed out. It may still be connecting in the background.",
"connectingHost": "Connecting…",
"connectHost": "Connect",
"setLocation": "Set project location",
"setLocationOnHost": "Set project location on {{host}}",
"setLocationTooltip": "Choose a folder or clone this project onto {{host}}.",
"addHost": "Add host",
"addHostHint": "Register another machine or Orca server",
"addSshHost": "Add SSH host",
@@ -12465,15 +12462,6 @@
},
"new": {
"workspace": {
"SetProjectLocationDialog": {
"title": "Set project location",
"description": "Choose where {{project}} lives on {{host}}.",
"browseFolder": "Browse folder",
"browseFolderHelp": "Use an existing checkout or folder on this host.",
"cloneFromUrl": "Clone from URL",
"cloneFromUrlHelp": "Clone this repository onto {{host}}.",
"saveLocation": "Set location"
},
"SmartWorkspaceNameField": {
"2a0d535f69": "Create new branch",
"a44229ce4d": "as workspace name",
@@ -270,30 +270,11 @@ describe('buildProjectHostSetupOptions', () => {
kind: 'needs-setup',
label: 'Builder',
detail: 'Project location not set',
isAvailable: true,
canSetLocation: true
isAvailable: true
})
])
})
// A `repo:<id>` project has no cross-host identity, so linking it on another host
// always fails in main — the row keeps its status line instead of a dead button.
it('cannot set a location for a host-local project', () => {
const options = buildProjectHostSetupOptions({
projectId: 'repo:local-repo',
eligibleRepos: [repo('local-repo')],
hosts: [host('local'), host('ssh:builder', { label: 'Builder' })],
projectHostSetups: [setup('local', 'repo:local-repo', 'local', 'local-repo')]
})
expect(options.at(-1)).toMatchObject({
kind: 'needs-setup',
detail: 'Project location not set',
isAvailable: true,
canSetLocation: false
})
})
it.each([
['connecting' as const, 'Connecting to host'],
['disconnected' as const, 'Connect this host to set up projects'],
@@ -426,8 +407,7 @@ describe('buildProjectHostSetupOptions', () => {
kind: 'needs-setup',
label: 'GPU VM',
detail: 'Project setup is in progress',
isAvailable: true,
canSetLocation: false
isAvailable: true
})
])
})
@@ -457,10 +437,7 @@ describe('buildProjectHostSetupOptions', () => {
})
]
}).at(-1)
).toMatchObject({
detail: 'Project tracked on this host but not set up',
canSetLocation: true
})
).toMatchObject({ detail: 'Project tracked on this host but not set up' })
expect(
buildProjectHostSetupOptions({
@@ -480,7 +457,7 @@ describe('buildProjectHostSetupOptions', () => {
})
]
}).at(-1)
).toMatchObject({ detail: 'Project setup needs attention', canSetLocation: true })
).toMatchObject({ detail: 'Project setup needs attention' })
expect(
buildProjectHostSetupOptions({
@@ -500,10 +477,7 @@ describe('buildProjectHostSetupOptions', () => {
})
]
}).at(-1)
).toMatchObject({
detail: 'Project is unsupported on this host',
canSetLocation: false
})
).toMatchObject({ detail: 'Project is unsupported on this host' })
})
it('marks incompatible runtime hosts as visible but unavailable', () => {
@@ -6,7 +6,6 @@ import {
type ExecutionHostId
} from '../../../shared/execution-host'
import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry'
import { isHostLocalProjectId } from '../../../shared/project-host-setup-projection'
import { isEphemeralVmRuntimeEnvironment } from '../../../shared/runtime-environments'
import {
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
@@ -37,9 +36,6 @@ export type ProjectHostSetupOption =
// Why: only a genuine connection error warrants an alarm glyph; a dormant
// disconnected host is merely not-yet-connected, not broken.
attention: boolean
// Why: available hosts without a path can be set up in place; connecting or
// in-progress/unsupported hosts need a different next step.
canSetLocation: boolean
connectAction?: { kind: 'ssh'; targetId: string } | { kind: 'runtime'; environmentId: string }
}
@@ -191,7 +187,6 @@ function buildNeedsSetupOptions({
: availability.detail,
isAvailable: availability.isAvailable,
attention: host.health === 'error',
canSetLocation: canSetProjectLocation(projectId, availability.isAvailable, pendingSetup),
...(connectAction ? { connectAction } : {})
}
})
@@ -268,23 +263,6 @@ function getHostHealthUnavailableDetail(
}
}
function canSetProjectLocation(
projectId: string,
isAvailable: boolean,
pendingSetup: ProjectHostSetup | undefined
): boolean {
// Why: setting up on another host links by project identity, and a host-local
// `repo:<id>` project has none to match against — the call always fails, so offer
// the plain status line rather than a button that only ever toasts an error.
if (!isAvailable || isHostLocalProjectId(projectId)) {
return false
}
if (!pendingSetup) {
return true
}
return pendingSetup.setupState === 'not-set-up' || pendingSetup.setupState === 'error'
}
function getHostConnectAction(
host: ExecutionHostRegistryEntry
): NeedsSetupProjectHostOption['connectAction'] | undefined {
+1 -12
View File
@@ -93,8 +93,6 @@ export function isProjectRemoteIdentityPending(
return repo.gitRemoteIdentity === undefined && !hasProjectRemoteIdentity(repo)
}
const HOST_LOCAL_PROJECT_ID_PREFIX = 'repo:'
export function getProjectIdentityKey(
repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
): string {
@@ -106,16 +104,7 @@ export function getProjectIdentityKey(
if (gitRemoteIdentity) {
return `git:${gitRemoteIdentity.canonicalKey}`
}
return `${HOST_LOCAL_PROJECT_ID_PREFIX}${repo.id}`
}
/**
* True for the `repo:<id>` fallback above — a folder project, or a git repo with no
* remote. The id is a per-host repo id, so the same project on another host derives a
* different one and can never be matched there.
*/
export function isHostLocalProjectId(projectId: string): boolean {
return projectId.startsWith(HOST_LOCAL_PROJECT_ID_PREFIX)
return `repo:${repo.id}`
}
export function getProjectIdForProviderIdentity(identity: ProjectProviderIdentity): string {