mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(workspace): set project location from the create-worktree host picker (#14868)
* feat(workspace): set project location from the create-worktree host picker Hosts that still say "Project location not set" now get an inline Set location action. It opens a nested dialog over Create worktree so the in-progress form stays put. * fix(workspace): replace unset-location status copy with a button Drop the redundant "Project location not set" caption and show a Set project location action with a hover tooltip instead. * refactor(workspace): tighten the set-project-location dialog - reuse CreateProjectParentBrowser instead of a second host-filesystem browse view - drop ProjectLocationBrowseTarget; parseExecutionHostId already models it - single setLocation path in RunTargetCombobox (row, button, Enter) - memoize the default clone URL instead of scanning on every store update - fix missing required props in the new composer-card test * fix(workspace): close the correctness gaps in set-project-location - Escape in the host browser now backs out to the form instead of dismissing the dialog and discarding the half-filled path/clone URL. Radix dismisses from a document-capture listener, so only preventDefault can stop it. - Drop the stopPropagation guards: window-capture (the composer's Escape handler) already ran by then, so they never protected it — the nestedDialogOpen gate does. They did silently kill RemoteFileBrowser's own key handling. - Hide Set project location for host-local repo:<id> projects (folder projects, git repos with no remote). Linking on another host matches by project identity, which those have none of, so the call could only ever toast an error. - Drop a standalone placeholder setup once a repo projection covers the same project+host, restoring the (projectId, hostId) uniqueness invariant. Setting a location on a host with a pending setup was leaving a ghost that sorts first and reads back as 'not set up'. - Existing-folder submit label matched a catalog string reading 'Importing...' * test(composer): follow the renamed local in the host-retarget source assertion
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
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,6 +23,10 @@ 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[]
|
||||
@@ -35,6 +39,7 @@ 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) => {
|
||||
@@ -48,6 +53,10 @@ 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(
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// @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,7 +31,9 @@ vi.mock('@/store', () => ({
|
||||
setRuntimeEnvironmentStatus: storeMocks.setRuntimeEnvironmentStatus,
|
||||
activeModal: 'none',
|
||||
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
|
||||
updateSettings: vi.fn()
|
||||
updateSettings: vi.fn(),
|
||||
projects: [],
|
||||
repos: []
|
||||
}),
|
||||
{
|
||||
getState: () => ({
|
||||
@@ -62,6 +64,10 @@ 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" />
|
||||
}))
|
||||
@@ -155,7 +161,8 @@ const devboxNeedsSetupHostOption: ProjectHostSetupOption = {
|
||||
label: 'Devbox',
|
||||
detail: 'Project location not set',
|
||||
isAvailable: true,
|
||||
attention: false
|
||||
attention: false,
|
||||
canSetLocation: true
|
||||
}
|
||||
|
||||
const disconnectedDevboxNeedsSetupHostOption: ProjectHostSetupOption = {
|
||||
@@ -167,6 +174,7 @@ const disconnectedDevboxNeedsSetupHostOption: ProjectHostSetupOption = {
|
||||
detail: 'Connect this host to set up projects',
|
||||
isAvailable: false,
|
||||
attention: false,
|
||||
canSetLocation: false,
|
||||
connectAction: { kind: 'ssh', targetId: 'devbox' }
|
||||
}
|
||||
|
||||
@@ -179,6 +187,7 @@ const disconnectedBastionNeedsSetupHostOption: ProjectHostSetupOption = {
|
||||
detail: 'Connect this host to set up projects',
|
||||
isAvailable: false,
|
||||
attention: false,
|
||||
canSetLocation: false,
|
||||
connectAction: { kind: 'ssh', targetId: 'bastion' }
|
||||
}
|
||||
|
||||
@@ -601,7 +610,8 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
|
||||
openRunTargetPicker(current.container)
|
||||
|
||||
const devboxItem = findRunTargetItem('Devbox')
|
||||
expect(devboxItem?.textContent).toContain('Project location not set')
|
||||
expect(devboxItem?.textContent).not.toContain('Project location not set')
|
||||
expect(devboxItem?.textContent).toContain('Set project location')
|
||||
// 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,6 +48,7 @@ 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
|
||||
@@ -162,6 +163,8 @@ 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>> = {
|
||||
@@ -371,7 +374,8 @@ export default function NewWorkspaceComposerCard({
|
||||
sparseSelectedPresetId,
|
||||
onSparseSelectPreset,
|
||||
sparseControlsEnabled = true,
|
||||
onAddProjectOverride
|
||||
onAddProjectOverride,
|
||||
onNestedDialogOpenChange
|
||||
}: NewWorkspaceComposerCardProps): React.JSX.Element {
|
||||
// Why: subscribe (form uses translate() directly) so an open create dialog repaints when the UI language changes.
|
||||
useTranslation()
|
||||
@@ -499,6 +503,38 @@ 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
|
||||
@@ -704,6 +740,7 @@ export default function NewWorkspaceComposerCard({
|
||||
onAddSshHost={handleAddSshHost}
|
||||
onAddRemoteServer={handleAddRemoteServer}
|
||||
onConnectHost={handleConnectRunTargetHost}
|
||||
onSetLocation={handleSetLocation}
|
||||
/>
|
||||
{ephemeralVmRecipeError ? (
|
||||
<p className="whitespace-pre-line text-[11px] text-destructive">
|
||||
@@ -1231,6 +1268,14 @@ 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,6 +197,7 @@ 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)
|
||||
@@ -238,10 +239,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
|
||||
const nestedDialogOpen = agentSettingsOpen || addProjectOpen || setLocationOpen
|
||||
useEffect(() => {
|
||||
if (!active || nestedDialogOpen) {
|
||||
// Why: while a nested dialog (Add Project / Agents) is layered on top,
|
||||
// Why: while a nested dialog (Add Project / Agents / Set location) 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
|
||||
@@ -324,6 +325,7 @@ function QuickTabBody({
|
||||
onOpenAgentSettings={() => setAgentSettingsOpen(true)}
|
||||
onCreate={() => void handleCreate()}
|
||||
onAddProjectOverride={handleOpenAddProject}
|
||||
onNestedDialogOpenChange={setSetLocationOpen}
|
||||
/>
|
||||
<AgentSettingsDialog open={agentSettingsOpen} onOpenChange={setAgentSettingsOpen} />
|
||||
{addProjectMounted ? (
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ConnectHostButton,
|
||||
HostRowIcon,
|
||||
NeedsSetupHostIcon,
|
||||
RunTargetRow
|
||||
RunTargetRow,
|
||||
SetLocationButton
|
||||
} from './RunTargetComboboxRow'
|
||||
import {
|
||||
buildRunTargetRows,
|
||||
@@ -34,6 +35,7 @@ type RunTargetComboboxProps = {
|
||||
onAddRemoteServer?: () => void
|
||||
onAddSshHost?: () => void
|
||||
onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise<void> | void
|
||||
onSetLocation?: (option: NeedsSetupProjectHostOption) => void
|
||||
}
|
||||
|
||||
const ROOT_ATTRIBUTE = 'data-run-target-combobox-root'
|
||||
@@ -56,7 +58,8 @@ export default function RunTargetCombobox({
|
||||
onRecipeChange,
|
||||
onAddRemoteServer,
|
||||
onAddSshHost,
|
||||
onConnectHost
|
||||
onConnectHost,
|
||||
onSetLocation
|
||||
}: 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.
|
||||
@@ -134,6 +137,19 @@ 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 => {
|
||||
@@ -146,12 +162,13 @@ export default function RunTargetCombobox({
|
||||
return
|
||||
}
|
||||
if (row.kind === 'needs-setup') {
|
||||
// Not ready: selecting is a no-op, the Connect action is the way forward.
|
||||
// Not ready: setting the location is the only way forward from the row itself.
|
||||
setLocation(row.option)
|
||||
return
|
||||
}
|
||||
setSubmenu(row.kind === 'recipes' ? 'recipes' : 'add-host')
|
||||
},
|
||||
[rows, selectHost]
|
||||
[rows, selectHost, setLocation]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -291,6 +308,7 @@ 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}
|
||||
@@ -302,10 +320,9 @@ export default function RunTargetCombobox({
|
||||
/>
|
||||
}
|
||||
label={row.option.label}
|
||||
// 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}
|
||||
// Why: Connect / Set project location already say the next
|
||||
// step, so the detail line would only repeat that.
|
||||
detail={hasConnect || hasSetLocation ? '' : row.option.detail}
|
||||
armed={isArmed}
|
||||
current={false}
|
||||
dimmed
|
||||
@@ -314,13 +331,18 @@ export default function RunTargetCombobox({
|
||||
arm(row.key)
|
||||
setSubmenu(null)
|
||||
}}
|
||||
onCommit={() => {}}
|
||||
onCommit={() => setLocation(row.option)}
|
||||
trailing={
|
||||
row.option.connectAction && onConnectHost ? (
|
||||
hasConnect ? (
|
||||
<ConnectHostButton
|
||||
connecting={connecting}
|
||||
onConnect={() => void connectHost(row.option)}
|
||||
/>
|
||||
) : hasSetLocation ? (
|
||||
<SetLocationButton
|
||||
hostLabel={row.option.label}
|
||||
onSetLocation={() => setLocation(row.option)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -173,3 +174,52 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// @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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,278 @@
|
||||
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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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(option.id)')
|
||||
expect(switchSection).toContain('setSelectedProjectHostSetupOverrideId(target.id)')
|
||||
expect(switchSection).toContain('preserveStartFrom: true')
|
||||
expect(switchSection).toContain('forceResetStartFrom: true')
|
||||
})
|
||||
|
||||
@@ -2796,12 +2796,21 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
const handleProjectHostSetupChange = useCallback(
|
||||
(setupId: string): void => {
|
||||
const option = projectHostSetupOptions.find((candidate) => candidate.id === setupId)
|
||||
if (!option || option.kind !== 'ready') {
|
||||
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) {
|
||||
return
|
||||
}
|
||||
// Why: switching run host for the same project must not erase the task/PR source the user is starting from.
|
||||
setSelectedProjectHostSetupOverrideId(option.id)
|
||||
handleRepoChange(option.repoId, {
|
||||
setSelectedProjectHostSetupOverrideId(target.id)
|
||||
handleRepoChange(target.repoId, {
|
||||
preserveStartFrom: true,
|
||||
forceResetStartFrom: true
|
||||
})
|
||||
|
||||
@@ -1491,6 +1491,9 @@
|
||||
"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",
|
||||
@@ -12943,6 +12946,15 @@
|
||||
},
|
||||
"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,11 +270,30 @@ describe('buildProjectHostSetupOptions', () => {
|
||||
kind: 'needs-setup',
|
||||
label: 'Builder',
|
||||
detail: 'Project location not set',
|
||||
isAvailable: true
|
||||
isAvailable: true,
|
||||
canSetLocation: 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'],
|
||||
@@ -407,7 +426,8 @@ describe('buildProjectHostSetupOptions', () => {
|
||||
kind: 'needs-setup',
|
||||
label: 'GPU VM',
|
||||
detail: 'Project setup is in progress',
|
||||
isAvailable: true
|
||||
isAvailable: true,
|
||||
canSetLocation: false
|
||||
})
|
||||
])
|
||||
})
|
||||
@@ -437,7 +457,10 @@ describe('buildProjectHostSetupOptions', () => {
|
||||
})
|
||||
]
|
||||
}).at(-1)
|
||||
).toMatchObject({ detail: 'Project tracked on this host but not set up' })
|
||||
).toMatchObject({
|
||||
detail: 'Project tracked on this host but not set up',
|
||||
canSetLocation: true
|
||||
})
|
||||
|
||||
expect(
|
||||
buildProjectHostSetupOptions({
|
||||
@@ -457,7 +480,7 @@ describe('buildProjectHostSetupOptions', () => {
|
||||
})
|
||||
]
|
||||
}).at(-1)
|
||||
).toMatchObject({ detail: 'Project setup needs attention' })
|
||||
).toMatchObject({ detail: 'Project setup needs attention', canSetLocation: true })
|
||||
|
||||
expect(
|
||||
buildProjectHostSetupOptions({
|
||||
@@ -477,7 +500,10 @@ describe('buildProjectHostSetupOptions', () => {
|
||||
})
|
||||
]
|
||||
}).at(-1)
|
||||
).toMatchObject({ detail: 'Project is unsupported on this host' })
|
||||
).toMatchObject({
|
||||
detail: 'Project is unsupported on this host',
|
||||
canSetLocation: false
|
||||
})
|
||||
})
|
||||
|
||||
it('marks incompatible runtime hosts as visible but unavailable', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -36,6 +37,9 @@ 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 }
|
||||
}
|
||||
|
||||
@@ -187,6 +191,7 @@ function buildNeedsSetupOptions({
|
||||
: availability.detail,
|
||||
isAvailable: availability.isAvailable,
|
||||
attention: host.health === 'error',
|
||||
canSetLocation: canSetProjectLocation(projectId, availability.isAvailable, pendingSetup),
|
||||
...(connectAction ? { connectAction } : {})
|
||||
}
|
||||
})
|
||||
@@ -263,6 +268,23 @@ 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 {
|
||||
|
||||
@@ -93,6 +93,8 @@ 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 {
|
||||
@@ -104,7 +106,16 @@ export function getProjectIdentityKey(
|
||||
if (gitRemoteIdentity) {
|
||||
return `git:${gitRemoteIdentity.canonicalKey}`
|
||||
}
|
||||
return `repo:${repo.id}`
|
||||
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)
|
||||
}
|
||||
|
||||
export function getProjectIdForProviderIdentity(identity: ProjectProviderIdentity): string {
|
||||
|
||||
Reference in New Issue
Block a user