Files
orca/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx
T
Neil e4e54a17d0 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
2026-08-16 03:30:47 -07:00

226 lines
6.8 KiB
TypeScript

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'
import { translate } from '@/i18n/i18n'
/** The local machine isn't a server — a monitor glyph reads as "this computer". */
export function HostRowIcon({ hostId }: { hostId: ExecutionHostId }): React.JSX.Element {
const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server
return <Icon className="size-3.5 shrink-0 text-muted-foreground" />
}
/**
* One run-target row. Shares the Project picker's shape — 32px, label and
* right-aligned detail on one baseline — so the two composer fields read as one
* control. `stacked` switches to a two-line card for the Add-host choices,
* where the description explains what you're picking rather than labelling a
* thing you already know.
*/
export function RunTargetRow({
icon,
label,
detail,
armed,
current,
optionId,
dimmed = false,
submenu = false,
stacked = false,
onArm,
onCommit,
trailing
}: {
icon: React.ReactNode
label: string
detail: string
armed: boolean
current: boolean
optionId: string | undefined
/** Not-ready hosts are dormant, not errors — quiet them without disabling. */
dimmed?: boolean
/** Opens a nested list, so it gets a trailing chevron. */
submenu?: boolean
/** Two-line card: label over description, for rows that need explaining. */
stacked?: boolean
onArm: () => void
onCommit: () => void
trailing?: React.ReactNode
}): React.JSX.Element {
return (
<div
role="option"
id={optionId}
aria-selected={armed}
// `option` supports aria-haspopup but not aria-expanded, so the row
// announces that it opens a menu without claiming an invalid state.
aria-haspopup={submenu ? 'menu' : undefined}
data-armed={armed || undefined}
data-current={current ? 'true' : undefined}
onMouseDown={(event) => event.preventDefault()}
onMouseMove={onArm}
onClick={onCommit}
className={cn(
'flex cursor-default gap-2 rounded-sm px-2 text-sm',
stacked ? 'items-center py-1.5' : 'h-8 items-baseline',
armed && 'bg-accent text-accent-foreground',
current && !armed && 'bg-accent/60'
)}
>
{/* Icons are glyphs, not text, so they centre on the row. */}
<span
className={cn(
'flex shrink-0 items-center',
stacked ? 'self-start pt-0.5' : 'h-8',
dimmed && 'opacity-60'
)}
>
{icon}
</span>
{stacked ? (
<span className="flex min-w-0 flex-1 flex-col">
<span className={cn('truncate', current && 'font-medium')}>{label}</span>
<span className="truncate text-xs text-muted-foreground">{detail}</span>
</span>
) : (
<>
<span
className={cn(
'max-w-[50%] shrink truncate',
current && 'font-medium',
dimmed && 'opacity-60'
)}
>
{label}
</span>
<ProjectOptionDetail
detail={detail}
className={cn(
'ml-auto min-w-0 flex-1 shrink-[999] justify-end pl-2 text-right text-xs text-muted-foreground',
dimmed && 'opacity-60'
)}
/>
</>
)}
{trailing}
{submenu ? (
<span className={cn('flex shrink-0 items-center pl-1.5', stacked ? 'self-center' : 'h-8')}>
<ChevronRight className="size-3.5 text-muted-foreground" />
</span>
) : null}
</div>
)
}
/** Glyph for a host that can't run yet: connecting, broken, or merely dormant. */
export function NeedsSetupHostIcon({
hostId,
connecting,
attention
}: {
hostId: ExecutionHostId
connecting: boolean
attention: boolean
}): React.JSX.Element {
if (connecting) {
return <LoaderCircle className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
}
if (attention) {
return <AlertTriangle className="size-3.5 shrink-0 text-muted-foreground" />
}
return <HostRowIcon hostId={hostId} />
}
/** Inline Connect action on a disconnected host row. */
export function ConnectHostButton({
connecting,
onConnect
}: {
connecting: boolean
onConnect: () => void
}): React.JSX.Element {
return (
<Button
type="button"
variant="ghost"
size="xs"
// Why: only the row being connected is disabled, so one slow connect
// can't lock out connecting to the others.
disabled={connecting}
className="ml-1 shrink-0 gap-1 self-center text-muted-foreground/70 hover:text-foreground"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
// Why: keep the picker open so the connecting state stays visible; the
// row updates in place once store SSH state resolves.
onConnect()
}}
>
{connecting ? (
<>
<LoaderCircle className="size-3 animate-spin" />
{translate('auto.components.NewWorkspaceComposerCard.connectingHost', 'Connecting…')}
</>
) : (
translate('auto.components.NewWorkspaceComposerCard.connectHost', 'Connect')
)}
</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>
)
}