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:
Neil
2026-08-16 03:30:47 -07:00
committed by GitHub
parent 757fae28d7
commit e4e54a17d0
18 changed files with 1243 additions and 26 deletions
@@ -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(