diff --git a/src/main/persistence/tracking-repos/project-host-compatibility.test.ts b/src/main/persistence/tracking-repos/project-host-compatibility.test.ts new file mode 100644 index 00000000000..540dc6955f7 --- /dev/null +++ b/src/main/persistence/tracking-repos/project-host-compatibility.test.ts @@ -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 & Pick): Repo { + return { + path: `/src/${overrides.id}`, + displayName: 'orca', + addedAt: 1, + upstream: { owner: 'acme', repo: 'orca' }, + ...overrides + } as Repo +} + +function pendingSetup(overrides: Partial = {}): 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' + ]) + }) +}) diff --git a/src/main/persistence/tracking-repos/project-host-compatibility.ts b/src/main/persistence/tracking-repos/project-host-compatibility.ts index 88e80ecdb93..0b51c758652 100644 --- a/src/main/persistence/tracking-repos/project-host-compatibility.ts +++ b/src/main/persistence/tracking-repos/project-host-compatibility.ts @@ -23,6 +23,10 @@ export function isRepoBackedProjectHostSetup( return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId) } +function projectHostKey(setup: Pick): string { + return `${setup.projectId}\u0000${setup.hostId}` +} + export function mergeProjectHostSetupCompatibilityState( state: Pick, 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( diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx new file mode 100644 index 00000000000..92c7e889075 --- /dev/null +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx @@ -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: () => +})) + +vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({ + AddRemoteHostDialog: () => null +})) + +vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({ + default: () => null +})) + +vi.mock('@/components/new-workspace/SmartWorkspaceNameField', () => ({ + default: () => +})) + +vi.mock('@/components/new-workspace/ProjectCombobox', () => ({ + default: () =>
+})) + +vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({ + SetProjectLocationDialog: ({ + option, + projectName + }: { + option: { label: string } | null + projectName: string + }) => + option ? ( +
+ ) : 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> = {} +): HTMLDivElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render( + {}} + 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('div[data-run-target-combobox-root="true"]')?.click() + }) + const setLocation = [...document.body.querySelectorAll('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() + }) +}) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 33fc740980b..9775cd06dae 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -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 ?
: null })) +vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({ + SetProjectLocationDialog: () => null +})) + vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({ default: () =>
})) @@ -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) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 4606a625c07..29b7760d055 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -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> = { @@ -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(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 => { const action = option.connectAction @@ -704,6 +740,7 @@ export default function NewWorkspaceComposerCard({ onAddSshHost={handleAddSshHost} onAddRemoteServer={handleAddRemoteServer} onConnectHost={handleConnectRunTargetHost} + onSetLocation={handleSetLocation} /> {ephemeralVmRecipeError ? (

@@ -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. */} +

) } diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 77b9cf64e52..2309ef54b50 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -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} /> {addProjectMounted ? ( diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx index 4b3d0a07ea9..4ca978baa07 100644 --- a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx @@ -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 + 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 ( } 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 ? ( void connectHost(row.option)} /> + ) : hasSetLocation ? ( + setLocation(row.option)} + /> ) : undefined } /> diff --git a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx index fb5fa811f07..4d7cb3df50f 100644 --- a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx @@ -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({ ) } + +/** 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 ( + + + + + + {translate( + 'auto.components.NewWorkspaceComposerCard.setLocationTooltip', + 'Choose a folder or clone this project onto {{host}}.', + { host: hostLabel } + )} + + + ) +} diff --git a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx new file mode 100644 index 00000000000..086d6599869 --- /dev/null +++ b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx @@ -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 + }) => ( +
+ + +
+ ) +})) + +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> = {} +): ReturnType { + const user = userEvent.setup() + render( + + ) + 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( + + ) + 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('[data-slot="dialog-close"]')?.click() + }) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.tsx b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.tsx new file mode 100644 index 00000000000..2362c0ba43b --- /dev/null +++ b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.tsx @@ -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 ( + { + if (!next) { + onClose() + } + }} + > + { + if (exitHostBrowser.current?.()) { + // Radix only skips onDismiss when the escape was defaultPrevented. + event.preventDefault() + } + }} + > + {activeOption ? ( + + ) : null} + + + ) +} + +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('choose') + const [browseField, setBrowseField] = useState('existing') + const [setupPath, setSetupPath] = useState('') + const [setupKind, setSetupKind] = useState(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 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 => { + 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 => { + 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 ( + setView(browseField)} + /> + ) + } + + return ( + <> + + + {translate( + 'auto.components.new.workspace.SetProjectLocationDialog.title', + 'Set project location' + )} + + + {translate( + 'auto.components.new.workspace.SetProjectLocationDialog.description', + 'Choose where {{project}} lives on {{host}}.', + { project: projectName, host: option.label } + )} + + + {view === 'choose' ? ( +
+ { + setView('existing') + // Local hosts get the native picker straight away — one click instead of two. + if (!remoteHost && !setupPath) { + void pickLocalProjectLocationFolder(setSetupPath) + } + }} + /> + {canClone ? ( + setView('clone')} + /> + ) : null} +
+ ) : null} + {view === 'existing' ? ( + setView('choose')} + onPathChange={setSetupPath} + onKindChange={setSetupKind} + onBrowse={() => openHostBrowser('existing')} + onSubmit={() => void handleExistingSubmit()} + /> + ) : null} + {view === 'clone' ? ( + setView('choose')} + onCloneUrlChange={setCloneUrl} + onCloneDestinationChange={setCloneDestination} + onBrowse={() => openHostBrowser('clone')} + onSubmit={() => void handleCloneSubmit()} + /> + ) : null} + + ) +} diff --git a/src/renderer/src/components/new-workspace/SetProjectLocationForms.tsx b/src/renderer/src/components/new-workspace/SetProjectLocationForms.tsx new file mode 100644 index 00000000000..1793e65a9cf --- /dev/null +++ b/src/renderer/src/components/new-workspace/SetProjectLocationForms.tsx @@ -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 ( +
+ +
+ 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} + /> + +
+ +
+ +
+
+ ) +} + +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 ( +
+ + onCloneUrlChange(event.target.value)} + placeholder={translate( + 'auto.components.settings.RepositoryPane.cloneUrlPlaceholder', + 'Repository URL' + )} + className="h-9 min-w-0" + spellCheck={false} + /> +
+ 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} + /> + +
+
+ +
+
+ ) +} + +export function LocationActionButton({ + icon: Icon, + title, + description, + onClick +}: { + icon: typeof FolderOpen + title: string + description: string + onClick: () => void +}): React.JSX.Element { + return ( + + ) +} + +function StepBackButton({ + onBack, + label +}: { + onBack: () => void + label: string +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/new-workspace/pick-local-project-folder.ts b/src/renderer/src/components/new-workspace/pick-local-project-folder.ts new file mode 100644 index 00000000000..c49f24b6b33 --- /dev/null +++ b/src/renderer/src/components/new-workspace/pick-local-project-folder.ts @@ -0,0 +1,14 @@ +import { toast } from 'sonner' + +export async function pickLocalProjectLocationFolder( + onPicked: (path: string) => void +): Promise { + try { + const path = await window.api.repos.pickFolder() + if (path) { + onPicked(path) + } + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } +} diff --git a/src/renderer/src/hooks/useComposerState-host-retarget.test.ts b/src/renderer/src/hooks/useComposerState-host-retarget.test.ts index 7e2c35cb75d..e057f401a4f 100644 --- a/src/renderer/src/hooks/useComposerState-host-retarget.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-retarget.test.ts @@ -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') }) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index ec91d1f969a..b74133490eb 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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 }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1be46cd92fc..d9d1be60ec2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/lib/project-host-setup-options.test.ts b/src/renderer/src/lib/project-host-setup-options.test.ts index 9eee19f9725..898fbd7b876 100644 --- a/src/renderer/src/lib/project-host-setup-options.test.ts +++ b/src/renderer/src/lib/project-host-setup-options.test.ts @@ -270,11 +270,30 @@ describe('buildProjectHostSetupOptions', () => { kind: 'needs-setup', label: 'Builder', detail: 'Project location not set', - isAvailable: true + isAvailable: true, + canSetLocation: true }) ]) }) + // A `repo:` 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', () => { diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts index eca4dc397c6..b4e975b0569 100644 --- a/src/renderer/src/lib/project-host-setup-options.ts +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -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:` 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 { diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 09f799e5527..8c3e5d15643 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -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 ): 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:` 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 {