feat(new-workspace): type-ahead Project and Run-on pickers (#11062)
* feat(new-workspace): make the project picker a type-ahead field The Create-worktree Project slot read as bulky and unpolished: a label row, an add-project icon, a 36px outline trigger and a chevron, all spent before choosing anything — then a popover carrying its own *second* search box, two-line rows, and a footer that scrolled out of reach. The field is now the search. Typing filters in place, so the nested search box is gone. Exactly one row is armed at any time and Enter takes it; hovering arms, so pointer and keyboard drive one cursor rather than two competing highlights. Armed is tracked by row key, not index, so a list arriving late over SSH cannot slide a different project under a keypress the user already aimed. Rows are single-line at 28px with an on-row Enter cap that takes space only while armed, and "Add a new project" is pinned to the popover edge so it survives every state — scrolled, filtered to nothing, or no projects at all. Long names and deep paths degrade deliberately: the name keeps up to half the row and the path elides from its middle, so two monorepo siblings stay distinguishable as …/services/checkout-api vs -web where a flat truncate rendered both identically. Recency is derived from when each project last had a workspace created, which is the action this picker is about to repeat — no new store field. The shell keeps data-project-combobox-root + role=combobox and stays focusable, so the composer's initial-focus and project-required handlers still land on it. * fix(new-workspace): align, scroll and loosen the project picker Five fixes to the type-ahead picker, three reported and two found while checking for related breakage. Alignment: the name and its smaller detail line were centred as boxes, so the 12px path sat visibly high against the 14px name. Both now share a baseline, in the committed field and in every row. The dot mark and the Enter cap are chips rather than text, so they stay centred on the row. Scrolling: the mouse wheel did nothing over the list. The composer is a Radix Dialog, and react-remove-scroll cancels wheel events for portaled content outside the dialog's DOM tree — the scrollbar dragged fine but the wheel was dead. The old cmdk list carried a shim for exactly this; the plain scroll pane that replaced it did not, so it has its own now. Density: rows go 28px -> 32px, row text 13px -> 14px and detail 11px -> 12px, with a taller Add row and more air above section headings. Escape stranded a query: with the list closed but text still typed, Escape was ignored (it was gated on the list being open), leaving the field showing text that matched nothing and hid the committed project. Escape now always restores the committed display, and only bubbles when there is nothing to undo. Listbox ownership: options sat inside unroled section and scroll wrappers, which breaks the listbox -> option relationship assistive tech relies on. Sections are groups carrying the heading as their label, and the scroll pane is presentational. Both new behaviours are covered by tests verified to fail without the fix. * chore(tools): keep the project-picker design lab The exploration harness behind the picker rewrite: 16 interactive design variants rendered against the app's real tokens and shadcn primitives, so a prototype is a drop-in ProjectCombobox rather than a mockup. Worth keeping because the frames encode bugs that only reproduce in context. DialogFrame renders the picker inside a real Radix Dialog, which is the only way the react-remove-scroll wheel bug shows up; the fixtures carry duplicate display names and deep sibling paths that a naive truncate renders identically. Run with: npx vite --config tools/wt-picker-lab/vite.config.ts * fix(new-workspace): stop the project list flashing open, shrink its empty state Opening the picker read as a double flash. The shared popover surface is translucent and fades 0 -> 1, which is right over the app canvas but wrong here: this popover lands directly on the composer dialog, so for the length of the fade the Name field underneath showed straight through the list and you saw two layers at once. The list now uses an opaque surface and zooms without fading, so it is solid from the first frame. Every other popover keeps the blur and fade. The "No projects match your search." state was a 60px centred block sitting next to 32px rows, which read as a different kind of surface and made an empty result feel like an error. It is now sized and aligned like a row. The lab's dialog frame focused whatever Radix picked first, which popped the Add-project tooltip on open and masked the real problem; it now focuses the name field the way the real composer does. * fix(new-workspace): square mark, centred empty state, and keep the list open on tab-focus Four fixes, three reported and one found while sweeping for others. Square mark: the option dot had a `rounded-full` override, so a project read as a circle here and a square everywhere else (jump palette, sidebar). Drop the override and use RepoBadgeMark's own shape. Centred empty state: "No projects match your search." was left-aligned after being shrunk to row height; centre it. Tab-focus blinked the list shut: the field lives in the popover's anchor, not inside its content, so Radix's dismissable layer saw focus land "outside" and closed the list the instant you tabbed in. Focus and pointer events within this control no longer dismiss it; genuine outside events still do. Junk text could strand the field: typing a query that matched nothing and then clicking away left the text sitting there with the list closed, showing no project and no error. A query only means something while the list is open, so closing without committing now clears it. On pressing Create with no project: no change needed. The create gate has not depended on project selection since #4991, and both submit paths already call showProjectRequiredError(), which sets the inline message and turns the field red via aria-invalid. Verified end to end: the button is pressable, the press paints the field destructive, and the message appears beneath it. * feat(new-workspace): rebuild the Run-on picker to match the project picker "Run on" was the last composer field still built the old way: an outline trigger wrapping a cmdk list, two-line rows, and no way to search. It now matches the project picker, so the two fields in the same form read as one control. The field is the search — type to filter hosts, paths and recipes with no nested search box. Exactly one row is armed at a time and Enter takes it; hovering arms, so pointer and keyboard drive one cursor. Rows are 32px with the label and its path on a shared baseline, the path eliding from its middle so two deep sibling paths stay distinguishable. The popover surface is opaque and unfaded because it lands on the composer dialog, where a translucent fade shows the form underneath. Two behaviours the project picker doesn't have are preserved. Disconnected hosts keep their inline Connect action, tracked per host so one stalled connect never blocks the others, and the list stays open so the connecting state is visible. Two rows open nested lists rather than committing: VM recipes, and "Add host" pinned to the popover edge so it survives every state — scrolled, filtered to nothing, or with no hosts at all. Enter and ArrowRight open a submenu; Escape backs out one layer at a time. Extracted from NewWorkspaceComposerCard (-563 lines) into files that each stay under the line limit without a suppression. Tests: the run-target cases asserted cmdk internals (`[cmdk-item]`, aria-disabled, cmdk-separator) that no longer exist. Rewritten against behaviour and the listbox roles instead. All 22 composer tests pass, plus a live sweep of 11 interactions in a real dialog. * fix(new-workspace): drop the Enter cap, fix submenu hover, match the Add rows Three follow-ups on the two composer pickers. The ↵ cap on the hovered row is gone from both. On a run-target row it sat next to the Connect action and read as a second, competing affordance; the highlight already says what Enter will take. Submenu rows never highlighted under the pointer. They passed a hardcoded `armed={false}`, so the recipe list and the Add-host choices were the only rows in either picker with no hover state. They now track their own hover. "Add a new project" used a chunky FolderPlus where "Add host" uses a plain Plus. Both rows were already the same height and type, so matching the glyph is the whole difference. * fix(new-workspace): restore the folder glyph, two-line Add-host cards, quiet Connect rows Three follow-ups. "Add a new project" goes back to FolderPlus — matching "Add host"'s plain Plus made the two consistent but lost the glyph that says which kind of thing is being added. A disconnected host row no longer repeats its status. The Connect button already says the host isn't connected, so "Connect this host to set up projects" beside it was saying it twice. Rows without a Connect action keep their detail, since there it explains why the host can't run. The Add-host choices go back to two-line cards. Their descriptions explain what you're picking ("Use an existing machine over SSH" vs "Pair another Orca runtime"), unlike a host row's detail, which just labels a host you already recognise. RunTargetRow grows a `stacked` variant for that rather than making the single-line row do both jobs. * fix(new-workspace): give Run on the same vertical rhythm as the other fields Run on is nested inside the Project block so the two share its error and empty states, which also put it on that block's 4px internal spacing. It reads as its own field, so it sat noticeably tighter than the 16px gap every other field in the composer gets. Pad it to match. * refactor(new-workspace): share the type-ahead machinery between both pickers Project and Run on were built one after the other, so each grew its own copy of the same mechanics: query and open state, arming by row key, the arrow-key walk, scroll-the-armed-row-into-view, the react-remove-scroll wheel shim, and the closes-drops-the-query rule. Two copies of subtle behaviour is two places for it to drift. useTypeAheadCombobox now owns all of it. Callers pass a function that turns a query into row keys and get back the query, the armed key, and the movement helpers. Run on layers its submenu state on top by wrapping `close`, which is the only part that isn't shared. The two long class strings both files repeated verbatim — the field shell and the opaque unfaded popover surface — are named constants now, so the reason they differ from the stock popover recipe is written down once instead of implied by a duplicated literal. No behaviour change: 16,456 renderer tests pass, plus the 22-check live interaction sweep across both pickers in a real dialog. * fix(new-workspace): drop aria-expanded from option rows, remove the design lab `aria-expanded` isn't a supported prop on `role="option"`, so the submenu rows were claiming a state screen readers can't interpret there. `aria-haspopup` alone already says the row opens a menu. Removes tools/wt-picker-lab. It was the harness for exploring this redesign — 12 interactive variants — and it did its job, but the 11 that lost are dead code, and its prototypes were the only thing failing the react-doctor gate (5 errors, all in throwaway variants; the shipped pickers had none).
This commit is contained in:
parent
89968a1061
commit
e73b1a1dd0
|
|
@ -280,15 +280,18 @@ function changeInputValue(input: HTMLInputElement, value: string): void {
|
|||
}
|
||||
|
||||
function openRunTargetPicker(container: HTMLElement): void {
|
||||
const runTargetButton = container.querySelector<HTMLButtonElement>('button[role="combobox"]')
|
||||
expect(runTargetButton).toBeTruthy()
|
||||
act(() => runTargetButton?.click())
|
||||
// The field is the search box; clicking its shell focuses it and opens the list.
|
||||
const runTargetShell = container.querySelector<HTMLElement>(
|
||||
'div[data-run-target-combobox-root="true"]'
|
||||
)
|
||||
expect(runTargetShell).toBeTruthy()
|
||||
act(() => runTargetShell?.click())
|
||||
}
|
||||
|
||||
function findRunTargetItem(label: string): HTMLElement | undefined {
|
||||
// Why: "Add host" is a pinned footer button (mirrors the Project combobox), not a cmdk row.
|
||||
// Rows are listbox options; "Add host" is the pinned footer row (also an option).
|
||||
return [
|
||||
...document.body.querySelectorAll<HTMLElement>('[cmdk-item], [data-run-target-add-host]')
|
||||
...document.body.querySelectorAll<HTMLElement>('[role="option"], [data-run-target-add-host]')
|
||||
].find((item) => item.textContent?.includes(label))
|
||||
}
|
||||
|
||||
|
|
@ -579,11 +582,10 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
|
|||
|
||||
const devboxItem = findRunTargetItem('Devbox')
|
||||
expect(devboxItem?.textContent).toContain('Project not set up on this host')
|
||||
// Not-connected rows stay highlightable (not disabled) so they hover like the other
|
||||
// items; a separator sets them off instead of a heading.
|
||||
expect(devboxItem?.getAttribute('aria-disabled')).toBe('false')
|
||||
expect(devboxItem?.getAttribute('data-disabled')).toBe('false')
|
||||
expect(document.body.querySelector('[cmdk-separator]')).toBeTruthy()
|
||||
// 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)
|
||||
expect(devboxItem?.getAttribute('role')).toBe('option')
|
||||
})
|
||||
|
||||
it('shows the run target picker for one ready setup so hosts can be added', () => {
|
||||
|
|
@ -623,10 +625,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
|
|||
|
||||
openRunTargetPicker(current.container)
|
||||
const devboxItem = findRunTargetItem('Devbox')
|
||||
expect(
|
||||
devboxItem?.getAttribute('aria-disabled') === 'true' ||
|
||||
devboxItem?.hasAttribute('data-disabled')
|
||||
).toBe(true)
|
||||
expect(devboxItem).toBeTruthy()
|
||||
const connectButton = [...(devboxItem?.querySelectorAll('button') ?? [])].find((button) =>
|
||||
button.textContent?.includes('Connect')
|
||||
)
|
||||
|
|
@ -723,10 +722,10 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
|
|||
openRunTargetPicker(current.container)
|
||||
const addHost = findRunTargetItem('Add host')
|
||||
expect(addHost).toBeTruthy()
|
||||
// Hovering the row (no click) opens its submenu so it feels like a menu. React derives
|
||||
// onPointerEnter from a bubbling pointerover, which is what jsdom dispatches here.
|
||||
// Hovering the row (no click) opens its submenu so it feels like a menu.
|
||||
// Hover arms rows via mousemove, matching the project picker.
|
||||
act(() => {
|
||||
addHost?.dispatchEvent(new MouseEvent('pointerover', { bubbles: true }))
|
||||
addHost?.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(findRunTargetItem('Add SSH host')).toBeTruthy()
|
||||
|
|
@ -785,10 +784,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
|
|||
expect(current.container.textContent).toContain('Run on')
|
||||
expect(current.container.textContent).not.toContain('VM recipe')
|
||||
|
||||
const runTargetButton =
|
||||
current.container.querySelector<HTMLButtonElement>('button[role="combobox"]')
|
||||
expect(runTargetButton).toBeTruthy()
|
||||
act(() => runTargetButton?.click())
|
||||
openRunTargetPicker(current.container)
|
||||
|
||||
expect(document.body.textContent).toContain('Per-Workspace Environment')
|
||||
const ephemeralVmItem = [
|
||||
|
|
@ -797,7 +793,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
|
|||
expect(ephemeralVmItem).toBeTruthy()
|
||||
act(() => ephemeralVmItem?.click())
|
||||
|
||||
const recipeItem = [...document.body.querySelectorAll<HTMLElement>('[cmdk-item]')].find(
|
||||
const recipeItem = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
|
||||
(item) => item.textContent?.includes('Vercel Sandbox')
|
||||
)
|
||||
expect(recipeItem).toBeTruthy()
|
||||
|
|
@ -839,12 +835,13 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
|
|||
onEphemeralVmRecipeChange: (recipeId) => recipeChanges.push(recipeId)
|
||||
})
|
||||
|
||||
const runTargetButton =
|
||||
current.container.querySelector<HTMLButtonElement>('button[role="combobox"]')
|
||||
expect(runTargetButton?.textContent).toContain('Per-Workspace Environment')
|
||||
act(() => runTargetButton?.click())
|
||||
const runTargetShell = current.container.querySelector<HTMLElement>(
|
||||
'div[data-run-target-combobox-root="true"]'
|
||||
)
|
||||
expect(runTargetShell?.textContent).toContain('Per-Workspace Environment')
|
||||
openRunTargetPicker(current.container)
|
||||
|
||||
const builderItem = [...document.body.querySelectorAll<HTMLElement>('[cmdk-item]')].find(
|
||||
const builderItem = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
|
||||
(item) => item.textContent?.includes('Builder')
|
||||
)
|
||||
expect(builderItem).toBeTruthy()
|
||||
|
|
|
|||
|
|
@ -1,33 +1,18 @@
|
|||
/* eslint-disable max-lines -- Why: keep the full composer card markup together so the inline and modal variants share one UI surface. */
|
||||
import React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Cloud,
|
||||
CornerDownLeft,
|
||||
FolderPlus,
|
||||
LoaderCircle,
|
||||
Monitor,
|
||||
PlugZap,
|
||||
Plus,
|
||||
Settings2,
|
||||
Server
|
||||
Settings2
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { SettingsSwitch } from '@/components/settings/SettingsFormControls'
|
||||
import type RepoCombobox from '@/components/repo/RepoCombobox'
|
||||
|
|
@ -63,6 +48,7 @@ import SmartWorkspaceNameField, {
|
|||
} from '@/components/new-workspace/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 {
|
||||
AddRemoteHostDialog,
|
||||
type AddRemoteHostMode
|
||||
|
|
@ -71,11 +57,9 @@ import type { SetupConfig } from '@/lib/new-workspace'
|
|||
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
|
||||
import type {
|
||||
NeedsSetupProjectHostOption,
|
||||
ProjectHostSetupOption,
|
||||
ReadyProjectHostSetupOption
|
||||
ProjectHostSetupOption
|
||||
} from '@/lib/project-host-setup-options'
|
||||
import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { SshConnectionStatus } from '../../../shared/ssh-types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import type { RuntimeStatus } from '../../../shared/runtime-types'
|
||||
|
|
@ -249,549 +233,6 @@ async function withUiConnectTimeout<T>(promise: Promise<T>): Promise<T> {
|
|||
}
|
||||
}
|
||||
|
||||
function getRecipeCommandDisplay(command: string): string {
|
||||
const trimmed = command.trim()
|
||||
const quoted = trimmed.match(/^"([^"]+)"/) ?? trimmed.match(/^'([^']+)'/)
|
||||
return quoted?.[1] ?? trimmed.split(/\s+/)[0] ?? trimmed
|
||||
}
|
||||
|
||||
function getRecipeDestroyLabel(recipe: EphemeralVmRecipeOption): string {
|
||||
if (recipe.destroyDisabled) {
|
||||
return translate('auto.components.NewWorkspaceComposerCard.destroyDisabled', 'destroy disabled')
|
||||
}
|
||||
if (recipe.destroy) {
|
||||
return translate(
|
||||
'auto.components.NewWorkspaceComposerCard.destroyConfigured',
|
||||
'destroy configured'
|
||||
)
|
||||
}
|
||||
return translate('auto.components.NewWorkspaceComposerCard.noDestroyConfigured', 'no destroy')
|
||||
}
|
||||
|
||||
type WorkspaceRunTargetComboboxProps = {
|
||||
hostOptions: readonly ProjectHostSetupOption[]
|
||||
hostValue: string | null
|
||||
onHostChange?: (setupId: string) => void
|
||||
recipes: EphemeralVmRecipeOption[]
|
||||
recipeValue: string | null
|
||||
onRecipeChange?: (recipeId: string | null) => void
|
||||
onAddRemoteServer?: () => void
|
||||
onAddSshHost?: () => void
|
||||
onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise<void> | void
|
||||
}
|
||||
|
||||
type HostPathTooltipPosition = {
|
||||
left: number
|
||||
top: number
|
||||
maxWidth: number
|
||||
}
|
||||
|
||||
const HOST_PATH_TOOLTIP_DELAY_MS = 400
|
||||
const HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX = 8
|
||||
const HOST_PATH_TOOLTIP_TRIGGER_GAP_PX = 4
|
||||
|
||||
function HostPathTooltip({ path }: { path: string }): React.JSX.Element {
|
||||
const tooltipId = React.useId()
|
||||
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pointerInsideRef = React.useRef(false)
|
||||
const [position, setPosition] = React.useState<HostPathTooltipPosition | null>(null)
|
||||
|
||||
const hideTooltip = React.useCallback((): void => {
|
||||
pointerInsideRef.current = false
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
setPosition(null)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => hideTooltip, [hideTooltip])
|
||||
|
||||
const handlePointerEnter = React.useCallback((event: React.PointerEvent<HTMLElement>): void => {
|
||||
pointerInsideRef.current = true
|
||||
const trigger = event.currentTarget
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
if (!pointerInsideRef.current || !trigger.isConnected) {
|
||||
return
|
||||
}
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
// Why: anchor under the hovered path, capping width to the viewport edge so a long path wraps instead of flying off-screen.
|
||||
const left = Math.max(HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX, rect.left)
|
||||
setPosition({
|
||||
left,
|
||||
top: rect.bottom + HOST_PATH_TOOLTIP_TRIGGER_GAP_PX,
|
||||
maxWidth: window.innerWidth - left - HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX
|
||||
})
|
||||
}, HOST_PATH_TOOLTIP_DELAY_MS)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Trigger is the truncated path line itself, so the tooltip only appears when hovering it. */}
|
||||
<div
|
||||
className="mt-0.5 truncate text-[11px] text-muted-foreground"
|
||||
aria-describedby={position ? tooltipId : undefined}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={hideTooltip}
|
||||
onPointerDown={hideTooltip}
|
||||
>
|
||||
{path}
|
||||
</div>
|
||||
{/* Why: a fixed, pointer-transparent portal cannot reflow cmdk or become the hover target. */}
|
||||
{position
|
||||
? createPortal(
|
||||
<div
|
||||
id={tooltipId}
|
||||
role="tooltip"
|
||||
data-slot="host-path-tooltip"
|
||||
className="pointer-events-none fixed z-[100] w-max break-all rounded-sm border border-border bg-popover px-1.5 py-1 font-mono text-[11px] leading-tight text-popover-foreground shadow-xs"
|
||||
style={{ left: position.left, top: position.top, maxWidth: position.maxWidth }}
|
||||
>
|
||||
{path}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the local machine isn't a server — give it a monitor glyph so it reads as "this computer".
|
||||
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" />
|
||||
}
|
||||
|
||||
function WorkspaceRunTargetCombobox({
|
||||
hostOptions,
|
||||
hostValue,
|
||||
onHostChange,
|
||||
recipes,
|
||||
recipeValue,
|
||||
onRecipeChange,
|
||||
onAddRemoteServer,
|
||||
onAddSshHost,
|
||||
onConnectHost
|
||||
}: WorkspaceRunTargetComboboxProps): React.JSX.Element {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [vmRecipesOpen, setVmRecipesOpen] = React.useState(false)
|
||||
const [hostActionsOpen, setHostActionsOpen] = React.useState(false)
|
||||
// Why: track in-flight connects per host (a Set, not a single id) so connecting one host — or
|
||||
// one connect stalling — never disables connecting to the others.
|
||||
const [connectingHostIds, setConnectingHostIds] = React.useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const readyHostOptions = React.useMemo(
|
||||
() =>
|
||||
hostOptions.filter(
|
||||
(option): option is ReadyProjectHostSetupOption => option.kind === 'ready'
|
||||
),
|
||||
[hostOptions]
|
||||
)
|
||||
const needsSetupHostOptions = React.useMemo(
|
||||
() =>
|
||||
hostOptions.filter(
|
||||
(option): option is NeedsSetupProjectHostOption => option.kind === 'needs-setup'
|
||||
),
|
||||
[hostOptions]
|
||||
)
|
||||
const selectedHost =
|
||||
readyHostOptions.find((option) => option.id === hostValue) ?? readyHostOptions[0] ?? null
|
||||
const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null
|
||||
const selectedValue = selectedRecipe
|
||||
? `recipe:${selectedRecipe.id}`
|
||||
: selectedHost
|
||||
? `host:${selectedHost.id}`
|
||||
: ''
|
||||
const ephemeralVmLabel = translate(
|
||||
'auto.components.NewWorkspaceComposerCard.ephemeralVm',
|
||||
'Per-Workspace Environment'
|
||||
)
|
||||
|
||||
const handleHostSelect = React.useCallback(
|
||||
(setupId: string): void => {
|
||||
if (!readyHostOptions.some((candidate) => candidate.id === setupId)) {
|
||||
return
|
||||
}
|
||||
onHostChange?.(setupId)
|
||||
onRecipeChange?.(null)
|
||||
setOpen(false)
|
||||
},
|
||||
[onHostChange, onRecipeChange, readyHostOptions]
|
||||
)
|
||||
|
||||
const handleRecipeSelect = React.useCallback(
|
||||
(recipeId: string): void => {
|
||||
if (!recipes.some((recipe) => recipe.id === recipeId)) {
|
||||
return
|
||||
}
|
||||
onRecipeChange?.(recipeId)
|
||||
setVmRecipesOpen(false)
|
||||
setOpen(false)
|
||||
},
|
||||
[onRecipeChange, recipes]
|
||||
)
|
||||
|
||||
const connectHost = React.useCallback(
|
||||
async (option: NeedsSetupProjectHostOption): Promise<void> => {
|
||||
if (!option.connectAction || !onConnectHost || connectingHostIds.has(option.hostId)) {
|
||||
return
|
||||
}
|
||||
setConnectingHostIds((current) => new Set(current).add(option.hostId))
|
||||
try {
|
||||
await onConnectHost(option)
|
||||
} finally {
|
||||
// Why: always clear the in-flight id when the connect settles (success, failure, or
|
||||
// timeout) so the row's spinner stops. No mounted-guard — a stale setState on an
|
||||
// unmounted component is a harmless no-op in React 18, and the guard's ref was the
|
||||
// source of a StrictMode bug that left the spinner stuck.
|
||||
setConnectingHostIds((current) => {
|
||||
if (!current.has(option.hostId)) {
|
||||
return current
|
||||
}
|
||||
const next = new Set(current)
|
||||
next.delete(option.hostId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
},
|
||||
[connectingHostIds, onConnectHost]
|
||||
)
|
||||
|
||||
// Why: the submenu rows open their popover on hover/focus (not just click) to feel like a
|
||||
// menu; opening one closes the other so the two right-side popovers never stack.
|
||||
const openVmRecipesSubmenu = React.useCallback((): void => {
|
||||
setHostActionsOpen(false)
|
||||
setVmRecipesOpen(true)
|
||||
}, [])
|
||||
|
||||
const openHostActionsSubmenu = React.useCallback((): void => {
|
||||
setVmRecipesOpen(false)
|
||||
setHostActionsOpen(true)
|
||||
}, [])
|
||||
|
||||
// Why: hovering/highlighting a plain host row dismisses any submenu popover left open from
|
||||
// passing over the recipe/add-host rows, so a stray submenu can't linger over the list.
|
||||
const closeSubmenus = React.useCallback((): void => {
|
||||
setVmRecipesOpen(false)
|
||||
setHostActionsOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleAddSshHost = React.useCallback((): void => {
|
||||
setHostActionsOpen(false)
|
||||
setOpen(false)
|
||||
onAddSshHost?.()
|
||||
}, [onAddSshHost])
|
||||
|
||||
const handleAddRemoteServer = React.useCallback((): void => {
|
||||
setHostActionsOpen(false)
|
||||
setOpen(false)
|
||||
onAddRemoteServer?.()
|
||||
}, [onAddRemoteServer])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-9 w-full justify-between border-input px-3 text-sm font-normal focus:border-ring focus:ring-[3px] focus:ring-ring/50"
|
||||
>
|
||||
{selectedRecipe ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{ephemeralVmLabel} / {selectedRecipe.name}
|
||||
</span>
|
||||
</span>
|
||||
) : selectedHost ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<HostRowIcon hostId={selectedHost.hostId} />
|
||||
<span className="truncate">{selectedHost.label}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.chooseRunTarget',
|
||||
'Choose target'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0"
|
||||
>
|
||||
<Command value={selectedValue}>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.noRunTargets',
|
||||
'No run targets are ready for this project.'
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{readyHostOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`host:${option.id}`}
|
||||
onSelect={() => handleHostSelect(option.id)}
|
||||
onPointerEnter={closeSubmenus}
|
||||
onFocus={closeSubmenus}
|
||||
className="items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-4 text-foreground',
|
||||
!selectedRecipe && option.id === selectedHost?.id ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<HostRowIcon hostId={option.hostId} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">{option.label}</div>
|
||||
<HostPathTooltip path={option.path} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
{/* Why: not-connected hosts are dormant, not errors — a separator (no heading) sets
|
||||
them off as a secondary group without labeling the recipe/add-host rows below. */}
|
||||
{needsSetupHostOptions.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
{needsSetupHostOptions.map((option) => (
|
||||
// Why: setup-on-host is a follow-up; this row only explains why the host cannot
|
||||
// run the workspace yet. It stays highlightable (no `disabled`) so it matches the
|
||||
// hover of the other rows, but selecting it is a no-op since it isn't ready.
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
onSelect={() => {}}
|
||||
onPointerEnter={closeSubmenus}
|
||||
onFocus={closeSubmenus}
|
||||
className="items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 opacity-60">
|
||||
<Check className="size-4 opacity-0" />
|
||||
{/* Why: show a spinner while connecting; otherwise reserve the alarm glyph for
|
||||
a genuine connection error and give a dormant disconnected host the neutral
|
||||
server icon. */}
|
||||
{connectingHostIds.has(option.hostId) ? (
|
||||
<LoaderCircle className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : option.attention ? (
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<HostRowIcon hostId={option.hostId} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">{option.label}</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
{option.detail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{option.connectAction && onConnectHost ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="ml-1 shrink-0 gap-1 text-muted-foreground/50 hover:text-muted-foreground"
|
||||
// Why: only disable the row being connected — not every row — so one
|
||||
// stuck/slow connect can't lock out connecting to the other hosts.
|
||||
disabled={connectingHostIds.has(option.hostId)}
|
||||
onPointerDown={(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 from store SSH state once the connect resolves.
|
||||
void connectHost(option)
|
||||
}}
|
||||
>
|
||||
{connectingHostIds.has(option.hostId) ? (
|
||||
<>
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.connectingHost',
|
||||
'Connecting…'
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
translate(
|
||||
'auto.components.NewWorkspaceComposerCard.connectHost',
|
||||
'Connect'
|
||||
)
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{/* Why: separate the host list from the per-workspace-env row; the "Add host" action
|
||||
is pinned below the list with its own border, so it needs no separator here. */}
|
||||
{recipes.length > 0 &&
|
||||
(readyHostOptions.length > 0 || needsSetupHostOptions.length > 0) ? (
|
||||
<CommandSeparator />
|
||||
) : null}
|
||||
{recipes.length > 0 ? (
|
||||
<Popover open={vmRecipesOpen} onOpenChange={setVmRecipesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{/* Why: a real CommandItem (not a raw button) so cmdk registers it — fixes missing rows, uneven height, and double-highlight. */}
|
||||
<CommandItem
|
||||
value="per-workspace-env"
|
||||
onSelect={openVmRecipesSubmenu}
|
||||
onPointerEnter={openVmRecipesSubmenu}
|
||||
onFocus={openVmRecipesSubmenu}
|
||||
className="items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-4 text-foreground',
|
||||
selectedRecipe ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">{ephemeralVmLabel}</div>
|
||||
{/* Why: a second line so this row matches the two-line host options above and hints what it opens. */}
|
||||
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.perWorkspaceEnvHint',
|
||||
'Provision an on-demand environment from a recipe'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</CommandItem>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right" align="start" sideOffset={6} className="w-72 p-0">
|
||||
<Command value={selectedRecipe ? `recipe:${selectedRecipe.id}` : ''}>
|
||||
<CommandList>
|
||||
{recipes.map((recipe) => (
|
||||
<CommandItem
|
||||
key={recipe.id}
|
||||
value={`recipe:${recipe.id}`}
|
||||
onSelect={() => handleRecipeSelect(recipe.id)}
|
||||
className="items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-4 text-foreground',
|
||||
recipe.id === selectedRecipe?.id ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">{recipe.name}</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
{getRecipeCommandDisplay(recipe.create)} ·{' '}
|
||||
{getRecipeDestroyLabel(recipe)}
|
||||
</div>
|
||||
{recipe.description ? (
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{recipe.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</CommandList>
|
||||
{/* Why: pin "Add host" below the scrollable list — mirrors the Project combobox's
|
||||
"Add a new project" footer so it keeps a compact single-row height and one clean
|
||||
divider instead of a taller in-list row above the popover edge. */}
|
||||
<div className="border-t border-border">
|
||||
<Popover open={hostActionsOpen} onOpenChange={setHostActionsOpen}>
|
||||
{/* Why: an Anchor (not a Trigger) so click/hover/focus all just open the submenu —
|
||||
a Trigger's own toggle would fight the hover-open and close it on the same click. */}
|
||||
<PopoverAnchor asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
data-run-target-add-host="true"
|
||||
onClick={openHostActionsSubmenu}
|
||||
onPointerEnter={openHostActionsSubmenu}
|
||||
onFocus={openHostActionsSubmenu}
|
||||
className="h-8 w-full justify-start gap-2 rounded-none px-3 text-xs font-normal"
|
||||
>
|
||||
<Plus className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span>
|
||||
{translate('auto.components.NewWorkspaceComposerCard.addHost', 'Add host')}
|
||||
</span>
|
||||
<ChevronRight className="ml-auto size-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent side="right" align="start" sideOffset={6} className="w-72 p-0">
|
||||
{/* Why: pin an empty value so cmdk doesn't auto-highlight the first row on open —
|
||||
matches the recipes submenu, which leaves nothing highlighted by default. */}
|
||||
<Command value="">
|
||||
<CommandList>
|
||||
<CommandItem
|
||||
value="add-ssh-host"
|
||||
onSelect={handleAddSshHost}
|
||||
className="items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Server className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addSshHost',
|
||||
'Add SSH host'
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addSshHostHint',
|
||||
'Use an existing machine over SSH'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
value="add-remote-orca-server"
|
||||
onSelect={handleAddRemoteServer}
|
||||
className="items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServer',
|
||||
'Add Remote Orca Server'
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServerHint',
|
||||
'Pair another Orca runtime'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function SetupCommandPreview({ setupConfig }: { setupConfig: SetupConfig }): React.JSX.Element {
|
||||
// Why: just the script in a quiet monochrome card — the source label (orca.yaml / local) and
|
||||
// the run-setup toggle live in the section header above, so the card carries no chrome of its
|
||||
|
|
@ -1258,11 +699,14 @@ export default function NewWorkspaceComposerCard({
|
|||
</p>
|
||||
) : null}
|
||||
{shouldShowRunTargetPicker ? (
|
||||
<div className="space-y-1">
|
||||
// Why: Run on is nested in the Project block (so they share the
|
||||
// error/empty states), which put it on the block's 4px rhythm. It's
|
||||
// its own field, so give it the 16px other fields get.
|
||||
<div className="space-y-1 pt-3">
|
||||
<label className="block min-w-0 truncate text-xs font-medium text-muted-foreground">
|
||||
{translate('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')}
|
||||
</label>
|
||||
<WorkspaceRunTargetCombobox
|
||||
<RunTargetCombobox
|
||||
hostOptions={projectHostSetupOptions}
|
||||
hostValue={selectedProjectHostSetupId ?? null}
|
||||
onHostChange={handleProjectHostSetupChange}
|
||||
|
|
|
|||
|
|
@ -6,34 +6,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
|
||||
import ProjectCombobox from './ProjectCombobox'
|
||||
|
||||
// Render the popover inline so assertions can reach the list without a portal.
|
||||
// `onOpenChange` is exposed on a button so a test can close the popover the way
|
||||
// Radix does on Escape — independently of the component's own key handler.
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/command', () => ({
|
||||
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandEmpty: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandInput: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
(props, ref) => <input ref={ref} {...props} />
|
||||
),
|
||||
CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandItem: ({
|
||||
Popover: ({
|
||||
children,
|
||||
onSelect,
|
||||
value
|
||||
onOpenChange
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onSelect?: (value: string) => void
|
||||
value: string
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => (
|
||||
<button type="button" data-command-value={value} onClick={() => onSelect?.(value)}>
|
||||
<div>
|
||||
<button type="button" data-test-close-popover onClick={() => onOpenChange?.(false)} />
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
</div>
|
||||
),
|
||||
PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
vi.mock('./use-recent-project-ids', () => ({ useRecentProjectIds: () => [] }))
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
|
|
@ -66,6 +60,41 @@ const projects: NewWorkspaceProjectOption[] = [
|
|||
}
|
||||
]
|
||||
|
||||
function field(): HTMLInputElement {
|
||||
const node = container.querySelector<HTMLInputElement>(
|
||||
'input[data-project-combobox-root="true"][role="combobox"]'
|
||||
)
|
||||
if (!node) {
|
||||
throw new Error('project combobox field not found')
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function openList(): void {
|
||||
act(() => {
|
||||
field().dispatchEvent(new FocusEvent('focus', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
function rowFor(optionId: string): HTMLElement {
|
||||
const row = Array.from(container.querySelectorAll<HTMLElement>('[role="option"]')).find((node) =>
|
||||
node.textContent?.includes(optionId)
|
||||
)
|
||||
if (!row) {
|
||||
throw new Error(`row not found for ${optionId}`)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function type(value: string): void {
|
||||
const input = field()
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
setter?.call(input, value)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
|
@ -87,9 +116,24 @@ describe('ProjectCombobox', () => {
|
|||
)
|
||||
})
|
||||
|
||||
const trigger = container.querySelector('[data-project-combobox-root="true"][role="combobox"]')
|
||||
expect(trigger?.textContent).toContain('orca')
|
||||
expect(trigger?.textContent).not.toContain('SSH')
|
||||
const shell = container.querySelector('[data-project-combobox-root="true"]')
|
||||
expect(shell?.textContent).toContain('orca')
|
||||
expect(shell?.textContent).not.toContain('SSH')
|
||||
})
|
||||
|
||||
it('keeps a focusable combobox that composer focus helpers can target', () => {
|
||||
act(() => {
|
||||
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
|
||||
})
|
||||
|
||||
const trigger = container.querySelector<HTMLElement>(
|
||||
'[data-project-combobox-root="true"][role="combobox"]'
|
||||
)
|
||||
expect(trigger).toBeTruthy()
|
||||
act(() => {
|
||||
trigger?.focus()
|
||||
})
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
it('selects projects by logical project id', () => {
|
||||
|
|
@ -104,10 +148,9 @@ describe('ProjectCombobox', () => {
|
|||
/>
|
||||
)
|
||||
})
|
||||
openList()
|
||||
act(() => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-command-value="github:stablyai/noqa"]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
rowFor('stablyai/noqa').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa')
|
||||
|
|
@ -126,14 +169,13 @@ describe('ProjectCombobox', () => {
|
|||
)
|
||||
})
|
||||
|
||||
const trigger = container.querySelector('[data-project-combobox-root="true"][role="combobox"]')
|
||||
expect(trigger?.textContent).toContain('Platform')
|
||||
const shell = container.querySelector('[data-project-combobox-root="true"]')
|
||||
expect(shell?.textContent).toContain('Platform')
|
||||
openList()
|
||||
expect(container.textContent).toContain('/tmp/platform')
|
||||
|
||||
act(() => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-command-value="project-group:folder-group"]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
rowFor('/tmp/platform').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('project-group:folder-group')
|
||||
|
|
@ -152,23 +194,53 @@ describe('ProjectCombobox', () => {
|
|||
/>
|
||||
)
|
||||
})
|
||||
openList()
|
||||
|
||||
const addButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(button) => button.textContent?.includes('Add a new project')
|
||||
const addRow = Array.from(container.querySelectorAll<HTMLElement>('[role="option"]')).find(
|
||||
(node) => node.textContent?.includes('Add a new project')
|
||||
)
|
||||
expect(addButton).toBeTruthy()
|
||||
expect(addRow).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
addButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
addRow?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onAddProject).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps "Add a new project" reachable when the search matches nothing', () => {
|
||||
const onAddProject = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value={null}
|
||||
onValueChange={vi.fn()}
|
||||
onAddProject={onAddProject}
|
||||
/>
|
||||
)
|
||||
})
|
||||
openList()
|
||||
type('zzzznomatch')
|
||||
|
||||
expect(container.textContent).toContain('No projects match your search.')
|
||||
const addRow = Array.from(container.querySelectorAll<HTMLElement>('[role="option"]')).find(
|
||||
(node) => node.textContent?.includes('Add a new project')
|
||||
)
|
||||
expect(addRow).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
addRow?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
expect(onAddProject).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('omits the "Add a new project" action when no handler is provided', () => {
|
||||
act(() => {
|
||||
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
|
||||
})
|
||||
openList()
|
||||
|
||||
expect(container.textContent).not.toContain('Add a new project')
|
||||
})
|
||||
|
|
@ -198,8 +270,163 @@ describe('ProjectCombobox', () => {
|
|||
<ProjectCombobox options={duplicateProjects} value={null} onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
openList()
|
||||
|
||||
expect(container.textContent).toContain('/workspace/storefront/merchant')
|
||||
expect(container.textContent).toContain('/workspace/admin/merchant')
|
||||
})
|
||||
|
||||
it('filters as the user types, without a second search box', () => {
|
||||
act(() => {
|
||||
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
|
||||
})
|
||||
openList()
|
||||
// The field itself is the only text input in the control.
|
||||
expect(container.querySelectorAll('input')).toHaveLength(1)
|
||||
|
||||
type('noq')
|
||||
expect(container.textContent).toContain('stablyai/noqa')
|
||||
expect(container.textContent).not.toContain('stablyai/orca')
|
||||
})
|
||||
|
||||
it('commits the armed row on Enter', () => {
|
||||
const onValueChange = vi.fn()
|
||||
const onValueSelected = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value={null}
|
||||
onValueChange={onValueChange}
|
||||
onValueSelected={onValueSelected}
|
||||
/>
|
||||
)
|
||||
})
|
||||
openList()
|
||||
type('noq')
|
||||
act(() => {
|
||||
field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa')
|
||||
expect(onValueSelected).toHaveBeenCalledWith('github:stablyai/noqa')
|
||||
})
|
||||
|
||||
it('arms "Add a new project" when a query matches nothing, so Enter is never a wrong guess', () => {
|
||||
const onAddProject = vi.fn()
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value={null}
|
||||
onValueChange={onValueChange}
|
||||
onAddProject={onAddProject}
|
||||
/>
|
||||
)
|
||||
})
|
||||
openList()
|
||||
type('zzzznomatch')
|
||||
act(() => {
|
||||
field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onAddProject).toHaveBeenCalledTimes(1)
|
||||
expect(onValueChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the committed project on Escape instead of stranding a stale query', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
openList()
|
||||
type('zzzznomatch')
|
||||
expect(field().value).toBe('zzzznomatch')
|
||||
// Radix closes the popover itself on Escape/outside-click, leaving a closed
|
||||
// list with a live query — the state that used to strand the field showing
|
||||
// text matching nothing while hiding the committed project.
|
||||
act(() => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-test-close-popover]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
})
|
||||
|
||||
expect(field().value).toBe('')
|
||||
const shell = container.querySelector('[data-project-combobox-root="true"]')
|
||||
expect(shell?.textContent).toContain('orca')
|
||||
})
|
||||
|
||||
it('drops an uncommitted query when the list closes, so junk text never persists', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
openList()
|
||||
type('asasdasd')
|
||||
expect(field().value).toBe('asasdasd')
|
||||
|
||||
// Blur / outside-click closes via Radix, not the key handler.
|
||||
act(() => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-test-close-popover]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(field().value).toBe('')
|
||||
const shell = container.querySelector('[data-project-combobox-root="true"]')
|
||||
expect(shell?.textContent).toContain('orca')
|
||||
})
|
||||
|
||||
it('marks the field invalid so a failed create press can turn it red', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value={null}
|
||||
onValueChange={vi.fn()}
|
||||
invalid
|
||||
describedBy="project-error"
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(field().getAttribute('aria-invalid')).toBe('true')
|
||||
expect(field().getAttribute('aria-describedby')).toBe('project-error')
|
||||
})
|
||||
|
||||
it('owns every option from the listbox, with no unroled wrapper in between', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value={null}
|
||||
onValueChange={vi.fn()}
|
||||
onAddProject={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
openList()
|
||||
|
||||
const listbox = container.querySelector('[role="listbox"]')
|
||||
expect(listbox).toBeTruthy()
|
||||
const options = Array.from(listbox?.querySelectorAll('[role="option"]') ?? [])
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
for (const option of options) {
|
||||
let parent = option.parentElement
|
||||
while (parent && parent !== listbox) {
|
||||
// A bare wrapper here breaks the listbox → option relationship for AT.
|
||||
expect(parent.getAttribute('role')).toBeTruthy()
|
||||
parent = parent.parentElement
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { Check, ChevronsUpDown, FolderOpen, FolderPlus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { ChevronDown, FolderPlus } from 'lucide-react'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
searchNewWorkspaceProjectOptions,
|
||||
type NewWorkspaceProjectOption
|
||||
} from '@/lib/new-workspace-project-options'
|
||||
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getAmbiguousProjectOptionIds,
|
||||
rankProjectOptions,
|
||||
sectionProjectOptions
|
||||
} from './project-combobox-matching'
|
||||
import { ProjectOptionDetail, ProjectOptionMark, ProjectOptionRow } from './ProjectComboboxRow'
|
||||
import { useRecentProjectIds } from './use-recent-project-ids'
|
||||
import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox'
|
||||
import { COMBOBOX_FIELD_SHELL, COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles'
|
||||
|
||||
type ProjectComboboxProps = {
|
||||
options: readonly NewWorkspaceProjectOption[]
|
||||
|
|
@ -29,6 +26,16 @@ type ProjectComboboxProps = {
|
|||
describedBy?: string
|
||||
}
|
||||
|
||||
const ADD_PROJECT_KEY = 'add-project'
|
||||
const ROOT_ATTRIBUTE = 'data-project-combobox-root'
|
||||
|
||||
/**
|
||||
* Type-ahead project picker: the field *is* the search, so there's no trigger
|
||||
* wrapping a second search box. Exactly one row is armed at any time and Enter
|
||||
* takes it; hovering arms, so the pointer and the keyboard drive one cursor.
|
||||
* "Add a new project" is pinned to the popover edge so it stays reachable
|
||||
* without scrolling, in every state including no-matches and no-projects.
|
||||
*/
|
||||
export default function ProjectCombobox({
|
||||
options,
|
||||
value,
|
||||
|
|
@ -40,217 +47,303 @@ export default function ProjectCombobox({
|
|||
invalid = false,
|
||||
describedBy
|
||||
}: ProjectComboboxProps): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [commandValue, setCommandValue] = useState('')
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
||||
const focusFrameRef = React.useRef<number | null>(null)
|
||||
const selectedProject = useMemo(
|
||||
() => options.find((option) => option.id === value) ?? null,
|
||||
[options, value]
|
||||
const recentIds = useRecentProjectIds()
|
||||
// Ranking depends on the query the hook owns, so rows are derived from it and
|
||||
// handed back; `matches`/`sections` are recomputed from the same query below.
|
||||
const deriveRowKeys = useCallback(
|
||||
(query: string): string[] => [
|
||||
...rankProjectOptions(options, query, recentIds).map((match) => match.option.id),
|
||||
...(onAddProject ? [ADD_PROJECT_KEY] : [])
|
||||
],
|
||||
[onAddProject, options, recentIds]
|
||||
)
|
||||
const filteredOptions = useMemo(
|
||||
() => searchNewWorkspaceProjectOptions(options, query),
|
||||
[options, query]
|
||||
const {
|
||||
query,
|
||||
setQuery,
|
||||
open,
|
||||
setOpen,
|
||||
close,
|
||||
handleOpenChange,
|
||||
armedKey,
|
||||
arm,
|
||||
moveArm,
|
||||
inputRef,
|
||||
listId,
|
||||
setListNode
|
||||
} = useTypeAheadCombobox(deriveRowKeys)
|
||||
|
||||
const matches = useMemo(
|
||||
() => rankProjectOptions(options, query, recentIds),
|
||||
[options, query, recentIds]
|
||||
)
|
||||
|
||||
const cancelFocusFrame = useCallback((): void => {
|
||||
if (focusFrameRef.current !== null) {
|
||||
cancelAnimationFrame(focusFrameRef.current)
|
||||
focusFrameRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setInputNode = useCallback(
|
||||
(node: HTMLInputElement | null): void => {
|
||||
if (node === null) {
|
||||
cancelFocusFrame()
|
||||
}
|
||||
inputRef.current = node
|
||||
},
|
||||
[cancelFocusFrame]
|
||||
const sections = useMemo(
|
||||
() => sectionProjectOptions(matches, query, recentIds),
|
||||
[matches, query, recentIds]
|
||||
)
|
||||
const ambiguous = useMemo(() => getAmbiguousProjectOptionIds(options), [options])
|
||||
const selected = options.find((option) => option.id === value) ?? null
|
||||
// A committed pick shows as the field's own content; typing replaces it.
|
||||
const committed = selected !== null && query.length === 0
|
||||
|
||||
const focusSearchInput = useCallback((): void => {
|
||||
cancelFocusFrame()
|
||||
focusFrameRef.current = requestAnimationFrame(() => {
|
||||
focusFrameRef.current = null
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}, [cancelFocusFrame])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean): void => {
|
||||
setOpen(nextOpen)
|
||||
if (nextOpen) {
|
||||
setCommandValue(value ?? '')
|
||||
const commit = useCallback(
|
||||
(key: string | null): void => {
|
||||
if (key === null) {
|
||||
return
|
||||
}
|
||||
cancelFocusFrame()
|
||||
setQuery('')
|
||||
},
|
||||
[cancelFocusFrame, value]
|
||||
)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(projectId: string): void => {
|
||||
onValueChange(projectId)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
onValueSelected?.(projectId)
|
||||
},
|
||||
[onValueChange, onValueSelected]
|
||||
)
|
||||
|
||||
const handleAddProject = useCallback((): void => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
onAddProject?.()
|
||||
}, [onAddProject])
|
||||
|
||||
const handleTriggerKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>): void => {
|
||||
if (open) {
|
||||
close()
|
||||
if (key === ADD_PROJECT_KEY) {
|
||||
onAddProject?.()
|
||||
return
|
||||
}
|
||||
onValueChange(key)
|
||||
onValueSelected?.(key)
|
||||
},
|
||||
[close, onAddProject, onValueChange, onValueSelected]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setCommandValue(value ?? '')
|
||||
setOpen(true)
|
||||
moveArm(event.key === 'ArrowDown' ? 1 : -1)
|
||||
return
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
if (event.key.length === 1 && /\S/.test(event.key)) {
|
||||
if (event.key === 'Enter' && open) {
|
||||
event.preventDefault()
|
||||
setCommandValue(value ?? '')
|
||||
setQuery(event.key)
|
||||
commit(armedKey)
|
||||
return
|
||||
}
|
||||
// Why: not gated on `open` — a leftover query with the list closed would
|
||||
// otherwise strand the field showing text that matches nothing and hides
|
||||
// the committed project. Escape always restores the committed display,
|
||||
// and only bubbles (to close the dialog) when there's nothing to undo.
|
||||
if (event.key === 'Escape' && (open || query.length > 0)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
return
|
||||
}
|
||||
// Backspace on a committed pick unsticks it back into editable text.
|
||||
if (event.key === 'Backspace' && committed && selected) {
|
||||
event.preventDefault()
|
||||
setQuery(selected.displayName)
|
||||
setOpen(true)
|
||||
}
|
||||
},
|
||||
[open, value]
|
||||
[armedKey, close, commit, committed, moveArm, open, query, selected, setOpen, setQuery]
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
data-project-combobox-root="true"
|
||||
onClick={() => {
|
||||
inputRef.current?.focus()
|
||||
setOpen(true)
|
||||
}}
|
||||
className={cn(
|
||||
'h-8 min-w-[184px] justify-between px-3 text-xs font-normal',
|
||||
COMBOBOX_FIELD_SHELL,
|
||||
invalid && 'border-destructive ring-destructive/20 dark:ring-destructive/40',
|
||||
triggerClassName
|
||||
)}
|
||||
data-project-combobox-root="true"
|
||||
>
|
||||
{selectedProject ? (
|
||||
selectedProject.kind === 'project-group' ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{selectedProject.displayName}</span>
|
||||
</span>
|
||||
) : (
|
||||
<RepoBadgeLabel
|
||||
name={selectedProject.displayName}
|
||||
color={selectedProject.badgeColor}
|
||||
badgeClassName="size-1.5"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
<ChevronsUpDown className="size-3.5 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<span className="flex w-4 shrink-0 items-center justify-center">
|
||||
{committed && selected ? <ProjectOptionMark option={selected} /> : null}
|
||||
</span>
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
data-project-combobox-root="true"
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.label',
|
||||
'Project'
|
||||
)}
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={open && armedKey ? `${listId}-armed` : undefined}
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
value={query}
|
||||
placeholder={committed ? '' : placeholder}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
|
||||
committed && 'text-transparent caret-foreground'
|
||||
)}
|
||||
/>
|
||||
{/* Painted over the input so a long name and its path can shrink at
|
||||
different rates instead of truncating as one flat string. */}
|
||||
{committed && selected ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-sm"
|
||||
>
|
||||
{/* Why: the outer row centres this group in the field, while the
|
||||
group itself is baseline-aligned — centring two different
|
||||
type sizes leaves the smaller one sitting visibly high. */}
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="min-w-0 max-w-[50%] shrink truncate">
|
||||
{selected.displayName}
|
||||
</span>
|
||||
<ProjectOptionDetail
|
||||
detail={selected.detail}
|
||||
className="min-w-0 flex-1 shrink-[999] justify-end text-xs text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.browse',
|
||||
'Browse projects'
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
inputRef.current?.focus()
|
||||
setOpen(!open)
|
||||
}}
|
||||
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0"
|
||||
data-project-combobox-root="true"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
focusSearchInput()
|
||||
sideOffset={4}
|
||||
// Why opaque + no fade: this popover lands directly on the composer
|
||||
// dialog, not the app canvas. The shared surface is translucent and
|
||||
// fades 0→1, so mid-animation the Name field underneath reads straight
|
||||
// through the list — two layers at once, which is the "double flash".
|
||||
// An opaque surface that zooms without fading resolves it. (`bg-popover`
|
||||
// alone loses to the primitive's arbitrary-value background, hence the
|
||||
// matching arbitrary form.) Every other caller keeps the blur and fade.
|
||||
className={cn(
|
||||
'flex w-[var(--radix-popover-trigger-width)] min-w-[17rem] flex-col p-0',
|
||||
COMBOBOX_POPOVER_SURFACE
|
||||
)}
|
||||
// Focus stays in the field — it's the search box — so the popover must
|
||||
// not steal it on open, nor yank it back on close after a pick.
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
// Why: the field lives in the anchor, not inside the content, so Radix
|
||||
// sees a focus/pointer event "outside" the layer and dismisses it the
|
||||
// instant you tab in. Keep the layer open whenever the interaction is
|
||||
// within this control; genuine outside events still close it.
|
||||
onFocusOutside={(event) => {
|
||||
if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
|
||||
<CommandInput
|
||||
ref={setInputNode}
|
||||
placeholder={translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.search',
|
||||
'Search projects...'
|
||||
)}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.empty',
|
||||
'No projects match your search.'
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{filteredOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
onSelect={() => handleSelect(option.id)}
|
||||
className="items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-4 text-foreground',
|
||||
option.id === value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{option.kind === 'project-group' ? (
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-sm">
|
||||
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{option.displayName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<RepoBadgeLabel
|
||||
name={option.displayName}
|
||||
color={option.badgeColor}
|
||||
className="max-w-full"
|
||||
/>
|
||||
)}
|
||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
{option.detail}
|
||||
</p>
|
||||
</div>
|
||||
</CommandItem>
|
||||
{/* The listbox wraps a scrolling pane plus the pinned Add row, so both
|
||||
stay `option` children of one listbox. */}
|
||||
<div
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.listLabel',
|
||||
'Projects'
|
||||
)}
|
||||
className="flex min-h-0 flex-col"
|
||||
>
|
||||
{/* Why: `presentation` — this element exists to scroll, and an
|
||||
unroled div between a listbox and its options breaks the
|
||||
ownership relationship assistive tech relies on. */}
|
||||
<div
|
||||
ref={setListNode}
|
||||
role="presentation"
|
||||
className="max-h-72 min-h-0 flex-1 overflow-y-auto p-1 scrollbar-sleek"
|
||||
>
|
||||
{matches.length === 0 ? (
|
||||
// Why: row-height rather than a tall centred block — a 60px panel
|
||||
// next to 32px rows reads as a different kind of surface and
|
||||
// makes an empty result feel like an error.
|
||||
<p className="flex h-8 items-center justify-center px-2 text-sm text-muted-foreground">
|
||||
{options.length === 0
|
||||
? translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.noProjects',
|
||||
'No projects yet.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.empty',
|
||||
'No projects match your search.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{sections.map((section) => (
|
||||
// Why: `role="group"` — a bare div between a listbox and its
|
||||
// options breaks the ownership relationship for screen readers,
|
||||
// which is the only thing that makes the headings announceable.
|
||||
<div key={section.key} role="group" aria-label={section.heading ?? undefined}>
|
||||
{section.heading ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="px-2 pt-2.5 pb-1 text-[11px] font-semibold tracking-[0.05em] text-muted-foreground uppercase"
|
||||
>
|
||||
{section.heading}
|
||||
</div>
|
||||
) : null}
|
||||
{section.items.map((scored) => (
|
||||
<ProjectOptionRow
|
||||
key={scored.option.id}
|
||||
option={scored.option}
|
||||
nameHits={scored.nameHits}
|
||||
detailHits={scored.detailHits}
|
||||
armed={armedKey === scored.option.id}
|
||||
current={scored.option.id === value}
|
||||
ambiguous={ambiguous.has(scored.option.id)}
|
||||
optionId={armedKey === scored.option.id ? `${listId}-armed` : undefined}
|
||||
onArm={() => arm(scored.option.id)}
|
||||
onCommit={() => commit(scored.option.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</CommandList>
|
||||
</div>
|
||||
{onAddProject ? (
|
||||
// Why: pinned below the scrollable list so "Add a new project" stays
|
||||
// reachable in every state, including when the list is empty and
|
||||
// CommandEmpty shows "No projects match your search."
|
||||
<div className="border-t border-border">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleAddProject}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => setCommandValue('')}
|
||||
className="h-8 w-full justify-start rounded-none px-3 text-xs font-normal"
|
||||
>
|
||||
<FolderPlus className="size-3.5 text-muted-foreground" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.addProject',
|
||||
'Add a new project'
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
<div
|
||||
role="option"
|
||||
id={armedKey === ADD_PROJECT_KEY ? `${listId}-armed` : undefined}
|
||||
aria-selected={armedKey === ADD_PROJECT_KEY}
|
||||
data-armed={armedKey === ADD_PROJECT_KEY || undefined}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseMove={() => arm(ADD_PROJECT_KEY)}
|
||||
onClick={() => commit(ADD_PROJECT_KEY)}
|
||||
className={cn(
|
||||
'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm',
|
||||
armedKey === ADD_PROJECT_KEY && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<FolderPlus className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.addProject',
|
||||
'Add a new project'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Command>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import React from 'react'
|
||||
import { FolderOpen } from 'lucide-react'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
|
||||
import { splitDetailForElision } from './project-combobox-matching'
|
||||
|
||||
/** Identity mark shared by the field and every row, so a project reads the same in both. */
|
||||
export function ProjectOptionMark({
|
||||
option
|
||||
}: {
|
||||
option: NewWorkspaceProjectOption
|
||||
}): React.JSX.Element {
|
||||
return option.kind === 'project-group' ? (
|
||||
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
// Square, matching the mark everywhere else (jump palette, sidebar).
|
||||
<RepoBadgeMark color={option.badgeColor} />
|
||||
)
|
||||
}
|
||||
|
||||
/** Underlines the matched characters so a fuzzy hit is legible, not mysterious. */
|
||||
export function MatchedText({
|
||||
text,
|
||||
hits,
|
||||
className
|
||||
}: {
|
||||
text: string
|
||||
hits: readonly number[]
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const marks = new Set(hits)
|
||||
if (marks.size === 0) {
|
||||
return <span className={cn('min-w-0 truncate', className)}>{text}</span>
|
||||
}
|
||||
return (
|
||||
<span className={cn('min-w-0 truncate', className)}>
|
||||
{[...text].map((char, index) =>
|
||||
marks.has(index) ? (
|
||||
<mark
|
||||
key={`${index}-${char}`}
|
||||
className="bg-transparent p-0 font-semibold text-foreground underline decoration-ring underline-offset-2"
|
||||
>
|
||||
{char}
|
||||
</mark>
|
||||
) : (
|
||||
char
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail line that keeps its final two path segments when space runs out —
|
||||
* `~/dev/…/services/checkout-api` stays distinct from its `-web` sibling, where
|
||||
* a plain truncate would render both identically.
|
||||
*/
|
||||
export function ProjectOptionDetail({
|
||||
detail,
|
||||
hits,
|
||||
className
|
||||
}: {
|
||||
detail: string
|
||||
hits?: readonly number[]
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const split = splitDetailForElision(detail)
|
||||
if (!split) {
|
||||
return (
|
||||
<span className={cn('min-w-0 truncate', className)} title={detail}>
|
||||
{hits ? <MatchedText text={detail} hits={hits} /> : detail}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={cn('flex min-w-0 items-baseline overflow-hidden', className)} title={detail}>
|
||||
{/* Head collapses first; the tail only truncates once the head is gone. */}
|
||||
<span className="min-w-0 shrink-[999] truncate">{split.head}</span>
|
||||
<span className="min-w-0 shrink truncate">/{split.tail}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectOptionRow({
|
||||
option,
|
||||
nameHits,
|
||||
detailHits,
|
||||
armed,
|
||||
current,
|
||||
ambiguous,
|
||||
optionId,
|
||||
onArm,
|
||||
onCommit
|
||||
}: {
|
||||
option: NewWorkspaceProjectOption
|
||||
nameHits: readonly number[]
|
||||
detailHits: readonly number[]
|
||||
armed: boolean
|
||||
current: boolean
|
||||
ambiguous: boolean
|
||||
optionId: string | undefined
|
||||
onArm: () => void
|
||||
onCommit: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="option"
|
||||
id={optionId}
|
||||
aria-selected={armed}
|
||||
data-armed={armed || undefined}
|
||||
data-current={current ? 'true' : undefined}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseMove={onArm}
|
||||
onClick={onCommit}
|
||||
className={cn(
|
||||
// Why: `items-baseline` (not `items-center`) — the name and its smaller
|
||||
// detail line sit on one baseline, so the two type sizes read as one
|
||||
// line rather than two boxes centred against each other.
|
||||
'flex h-8 cursor-default items-baseline gap-2 rounded-sm px-2 text-sm',
|
||||
armed && 'bg-accent text-accent-foreground',
|
||||
current && !armed && 'bg-accent/60'
|
||||
)}
|
||||
>
|
||||
{/* The mark is a dot, not text, so it centres on the row itself. */}
|
||||
<span className="flex h-8 shrink-0 items-center">
|
||||
<ProjectOptionMark option={option} />
|
||||
</span>
|
||||
{/* Name keeps up to half the row; the path absorbs the rest so a deep
|
||||
path can't squeeze the name down to "chec…". */}
|
||||
<MatchedText
|
||||
text={option.displayName}
|
||||
hits={nameHits}
|
||||
className={cn('max-w-[50%] shrink', current && 'font-medium')}
|
||||
/>
|
||||
<ProjectOptionDetail
|
||||
detail={option.detail}
|
||||
hits={detailHits}
|
||||
className={cn(
|
||||
'ml-auto min-w-0 flex-1 shrink-[999] justify-end pl-2 text-right text-xs',
|
||||
ambiguous ? 'text-foreground/80' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { Popover, PopoverContent } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type {
|
||||
NeedsSetupProjectHostOption,
|
||||
ProjectHostSetupOption
|
||||
} from '@/lib/project-host-setup-options'
|
||||
import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox'
|
||||
import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles'
|
||||
import {
|
||||
ConnectHostButton,
|
||||
HostRowIcon,
|
||||
NeedsSetupHostIcon,
|
||||
RunTargetRow
|
||||
} from './RunTargetComboboxRow'
|
||||
import {
|
||||
buildRunTargetRows,
|
||||
getEphemeralVmLabel,
|
||||
getRecipeDetail,
|
||||
RUN_TARGET_ADD_HOST_KEY,
|
||||
type EphemeralVmRecipeOption
|
||||
} from './run-target-options'
|
||||
import { AddHostSubmenuRow, RecipesSubmenuRow } from './RunTargetSubmenus'
|
||||
import RunTargetField from './RunTargetField'
|
||||
|
||||
type RunTargetComboboxProps = {
|
||||
hostOptions: readonly ProjectHostSetupOption[]
|
||||
hostValue: string | null
|
||||
onHostChange?: (setupId: string) => void
|
||||
recipes: EphemeralVmRecipeOption[]
|
||||
recipeValue: string | null
|
||||
onRecipeChange?: (recipeId: string | null) => void
|
||||
onAddRemoteServer?: () => void
|
||||
onAddSshHost?: () => void
|
||||
onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise<void> | void
|
||||
}
|
||||
|
||||
const ROOT_ATTRIBUTE = 'data-run-target-combobox-root'
|
||||
|
||||
/**
|
||||
* Run-target picker, built to match the project picker: the field *is* the
|
||||
* search, exactly one row is armed and Enter takes it, hovering arms, and the
|
||||
* "Add host" row is pinned to the popover edge so it survives every state.
|
||||
*
|
||||
* Two things the project picker doesn't have: disconnected hosts carry an
|
||||
* inline Connect action that must not select the row, and two rows open nested
|
||||
* lists (VM recipes, Add host) rather than committing.
|
||||
*/
|
||||
export default function RunTargetCombobox({
|
||||
hostOptions,
|
||||
hostValue,
|
||||
onHostChange,
|
||||
recipes,
|
||||
recipeValue,
|
||||
onRecipeChange,
|
||||
onAddRemoteServer,
|
||||
onAddSshHost,
|
||||
onConnectHost
|
||||
}: RunTargetComboboxProps): React.JSX.Element {
|
||||
const [submenu, setSubmenu] = useState<'recipes' | 'add-host' | null>(null)
|
||||
// Track in-flight connects per host so one stalling connect never blocks the others.
|
||||
const [connectingHostIds, setConnectingHostIds] = useState<ReadonlySet<string>>(() => new Set())
|
||||
|
||||
const hasAddHost = Boolean(onAddSshHost || onAddRemoteServer)
|
||||
const deriveRowKeys = useCallback(
|
||||
(query: string): string[] =>
|
||||
buildRunTargetRows({ hostOptions, recipes, query, hasAddHost }).rows.map((row) => row.key),
|
||||
[hasAddHost, hostOptions, recipes]
|
||||
)
|
||||
const combobox = useTypeAheadCombobox(deriveRowKeys)
|
||||
const { query, setQuery, open, setOpen, armedKey, arm, moveArm, inputRef, listId, setListNode } =
|
||||
combobox
|
||||
|
||||
const { rows, matchedRecipes } = useMemo(
|
||||
() => buildRunTargetRows({ hostOptions, recipes, query, hasAddHost }),
|
||||
[hasAddHost, hostOptions, query, recipes]
|
||||
)
|
||||
const readyHostOptions = useMemo(
|
||||
() => hostOptions.filter((option) => option.kind === 'ready'),
|
||||
[hostOptions]
|
||||
)
|
||||
const selectedHost =
|
||||
readyHostOptions.find((option) => option.id === hostValue) ?? readyHostOptions[0] ?? null
|
||||
const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null
|
||||
const armedRow = rows.find((row) => row.key === armedKey) ?? rows[0] ?? null
|
||||
// Only a committed selection paints the field; typing replaces it.
|
||||
const committed = query.length === 0 && (selectedRecipe !== null || selectedHost !== null)
|
||||
|
||||
// Closing also drops any open submenu, which the shared hook doesn't know about.
|
||||
const close = useCallback((): void => {
|
||||
combobox.close()
|
||||
setSubmenu(null)
|
||||
}, [combobox])
|
||||
|
||||
const selectHost = useCallback(
|
||||
(setupId: string): void => {
|
||||
onHostChange?.(setupId)
|
||||
onRecipeChange?.(null)
|
||||
close()
|
||||
},
|
||||
[close, onHostChange, onRecipeChange]
|
||||
)
|
||||
|
||||
const selectRecipe = useCallback(
|
||||
(recipeId: string): void => {
|
||||
onRecipeChange?.(recipeId)
|
||||
close()
|
||||
},
|
||||
[close, onRecipeChange]
|
||||
)
|
||||
|
||||
const connectHost = useCallback(
|
||||
async (option: NeedsSetupProjectHostOption): Promise<void> => {
|
||||
if (!option.connectAction || !onConnectHost || connectingHostIds.has(option.hostId)) {
|
||||
return
|
||||
}
|
||||
setConnectingHostIds((current) => new Set(current).add(option.hostId))
|
||||
try {
|
||||
await onConnectHost(option)
|
||||
} finally {
|
||||
// Always clear when the connect settles (success, failure, or timeout)
|
||||
// so the row's spinner stops.
|
||||
setConnectingHostIds((current) => {
|
||||
if (!current.has(option.hostId)) {
|
||||
return current
|
||||
}
|
||||
const next = new Set(current)
|
||||
next.delete(option.hostId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
},
|
||||
[connectingHostIds, onConnectHost]
|
||||
)
|
||||
|
||||
/** Commits a row, or opens its submenu when the row is a submenu row. */
|
||||
const activate = useCallback(
|
||||
(key: string | null): void => {
|
||||
const row = rows.find((candidate) => candidate.key === key)
|
||||
if (!row) {
|
||||
return
|
||||
}
|
||||
if (row.kind === 'ready') {
|
||||
selectHost(row.option.id)
|
||||
return
|
||||
}
|
||||
if (row.kind === 'needs-setup') {
|
||||
// Not ready: selecting is a no-op, the Connect action is the way forward.
|
||||
return
|
||||
}
|
||||
setSubmenu(row.kind === 'recipes' ? 'recipes' : 'add-host')
|
||||
},
|
||||
[rows, selectHost]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
moveArm(event.key === 'ArrowDown' ? 1 : -1)
|
||||
setSubmenu(null)
|
||||
return
|
||||
}
|
||||
if ((event.key === 'Enter' || event.key === 'ArrowRight') && open) {
|
||||
event.preventDefault()
|
||||
activate(armedRow?.key ?? null)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape' && (open || query.length > 0)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// A submenu closes first, so Escape backs out one layer at a time.
|
||||
if (submenu !== null) {
|
||||
setSubmenu(null)
|
||||
return
|
||||
}
|
||||
close()
|
||||
}
|
||||
},
|
||||
[activate, armedRow, close, moveArm, open, query, setOpen, submenu]
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean): void => {
|
||||
if (next) {
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
close()
|
||||
},
|
||||
[close, setOpen]
|
||||
)
|
||||
|
||||
const fieldLabel = selectedRecipe
|
||||
? `${getEphemeralVmLabel()} / ${selectedRecipe.name}`
|
||||
: (selectedHost?.label ?? '')
|
||||
const fieldDetail = selectedRecipe ? getRecipeDetail(selectedRecipe) : (selectedHost?.path ?? '')
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<RunTargetField
|
||||
query={query}
|
||||
onQueryChange={(value) => {
|
||||
setQuery(value)
|
||||
setOpen(true)
|
||||
setSubmenu(null)
|
||||
}}
|
||||
open={open}
|
||||
onOpenRequest={() => setOpen(true)}
|
||||
onToggle={() => setOpen(!open)}
|
||||
committed={committed}
|
||||
isRecipe={selectedRecipe !== null}
|
||||
hostId={selectedHost?.hostId ?? null}
|
||||
label={fieldLabel}
|
||||
detail={fieldDetail}
|
||||
listId={listId}
|
||||
hasArmedRow={armedRow !== null}
|
||||
inputRef={inputRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className={cn(
|
||||
'flex w-[var(--radix-popover-trigger-width)] min-w-[18rem] flex-col p-0',
|
||||
COMBOBOX_POPOVER_SURFACE
|
||||
)}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
// The field lives in the anchor, not the content, so Radix would see a
|
||||
// focus/pointer event "outside" and dismiss the instant you tab in.
|
||||
onFocusOutside={(event) => {
|
||||
if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.RunTargetCombobox.listLabel',
|
||||
'Run targets'
|
||||
)}
|
||||
className="flex min-h-0 flex-col"
|
||||
>
|
||||
<div
|
||||
ref={setListNode}
|
||||
role="presentation"
|
||||
className="max-h-72 min-h-0 flex-1 overflow-y-auto p-1 scrollbar-sleek"
|
||||
>
|
||||
{rows.filter((row) => row.kind !== 'add-host').length === 0 ? (
|
||||
<p className="flex h-8 items-center justify-center px-2 text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.NewWorkspaceComposerCard.noRunTargets',
|
||||
'No run targets are ready for this project.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{rows.map((row) => {
|
||||
if (row.kind === 'add-host') {
|
||||
return null
|
||||
}
|
||||
const isArmed = armedRow?.key === row.key
|
||||
const optionId = isArmed ? `${listId}-armed` : undefined
|
||||
if (row.kind === 'ready') {
|
||||
return (
|
||||
<RunTargetRow
|
||||
key={row.key}
|
||||
icon={<HostRowIcon hostId={row.option.hostId} />}
|
||||
label={row.option.label}
|
||||
detail={row.option.path}
|
||||
armed={isArmed}
|
||||
current={selectedRecipe === null && row.option.id === selectedHost?.id}
|
||||
optionId={optionId}
|
||||
onArm={() => {
|
||||
arm(row.key)
|
||||
setSubmenu(null)
|
||||
}}
|
||||
onCommit={() => selectHost(row.option.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'needs-setup') {
|
||||
const connecting = connectingHostIds.has(row.option.hostId)
|
||||
const hasConnect = Boolean(row.option.connectAction && onConnectHost)
|
||||
return (
|
||||
<RunTargetRow
|
||||
key={row.key}
|
||||
icon={
|
||||
<NeedsSetupHostIcon
|
||||
hostId={row.option.hostId}
|
||||
connecting={connecting}
|
||||
attention={row.option.attention}
|
||||
/>
|
||||
}
|
||||
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}
|
||||
armed={isArmed}
|
||||
current={false}
|
||||
dimmed
|
||||
optionId={optionId}
|
||||
onArm={() => {
|
||||
arm(row.key)
|
||||
setSubmenu(null)
|
||||
}}
|
||||
onCommit={() => {}}
|
||||
trailing={
|
||||
row.option.connectAction && onConnectHost ? (
|
||||
<ConnectHostButton
|
||||
connecting={connecting}
|
||||
onConnect={() => void connectHost(row.option)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// Recipes submenu row.
|
||||
return (
|
||||
<RecipesSubmenuRow
|
||||
key={row.key}
|
||||
open={submenu === 'recipes'}
|
||||
onOpenChange={(next) => setSubmenu(next ? 'recipes' : null)}
|
||||
armed={isArmed}
|
||||
optionId={optionId}
|
||||
recipes={matchedRecipes}
|
||||
selectedRecipeId={selectedRecipe?.id ?? null}
|
||||
onArm={() => {
|
||||
arm(row.key)
|
||||
setSubmenu('recipes')
|
||||
}}
|
||||
onSelectRecipe={selectRecipe}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{hasAddHost ? (
|
||||
<AddHostSubmenuRow
|
||||
open={submenu === 'add-host'}
|
||||
onOpenChange={(next) => setSubmenu(next ? 'add-host' : null)}
|
||||
armed={armedRow?.key === RUN_TARGET_ADD_HOST_KEY}
|
||||
optionId={armedRow?.key === RUN_TARGET_ADD_HOST_KEY ? `${listId}-armed` : undefined}
|
||||
onArm={() => {
|
||||
arm(RUN_TARGET_ADD_HOST_KEY)
|
||||
setSubmenu('add-host')
|
||||
}}
|
||||
{...(onAddSshHost
|
||||
? {
|
||||
onAddSshHost: () => {
|
||||
close()
|
||||
onAddSshHost()
|
||||
}
|
||||
}
|
||||
: {})}
|
||||
{...(onAddRemoteServer
|
||||
? {
|
||||
onAddRemoteServer: () => {
|
||||
close()
|
||||
onAddRemoteServer()
|
||||
}
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import React from 'react'
|
||||
import { AlertTriangle, ChevronRight, LoaderCircle, Monitor, Server } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import React from 'react'
|
||||
import { ChevronDown, Cloud } from 'lucide-react'
|
||||
import { PopoverAnchor } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { HostRowIcon } from './RunTargetComboboxRow'
|
||||
import { COMBOBOX_FIELD_SHELL } from './type-ahead-combobox-styles'
|
||||
|
||||
/**
|
||||
* The run-target field: a text input that *is* the search box, painted over
|
||||
* with the committed selection so a long label and its path shrink at
|
||||
* different rates instead of truncating as one flat string.
|
||||
*/
|
||||
export default function RunTargetField({
|
||||
query,
|
||||
onQueryChange,
|
||||
open,
|
||||
onOpenRequest,
|
||||
onToggle,
|
||||
committed,
|
||||
isRecipe,
|
||||
hostId,
|
||||
label,
|
||||
detail,
|
||||
listId,
|
||||
hasArmedRow,
|
||||
inputRef,
|
||||
onKeyDown
|
||||
}: {
|
||||
query: string
|
||||
onQueryChange: (value: string) => void
|
||||
open: boolean
|
||||
onOpenRequest: () => void
|
||||
onToggle: () => void
|
||||
committed: boolean
|
||||
isRecipe: boolean
|
||||
hostId: ExecutionHostId | null
|
||||
label: string
|
||||
detail: string
|
||||
listId: string
|
||||
hasArmedRow: boolean
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
data-run-target-combobox-root="true"
|
||||
onClick={() => {
|
||||
inputRef.current?.focus()
|
||||
onOpenRequest()
|
||||
}}
|
||||
className={COMBOBOX_FIELD_SHELL}
|
||||
>
|
||||
<span className="flex w-4 shrink-0 items-center justify-center">
|
||||
{committed ? (
|
||||
isRecipe ? (
|
||||
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : hostId ? (
|
||||
<HostRowIcon hostId={hostId} />
|
||||
) : null
|
||||
) : null}
|
||||
</span>
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
data-run-target-combobox-root="true"
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.RunTargetCombobox.label',
|
||||
'Run on'
|
||||
)}
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={open && hasArmedRow ? `${listId}-armed` : undefined}
|
||||
value={query}
|
||||
placeholder={
|
||||
committed
|
||||
? ''
|
||||
: translate(
|
||||
'auto.components.NewWorkspaceComposerCard.chooseRunTarget',
|
||||
'Choose target'
|
||||
)
|
||||
}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onFocus={onOpenRequest}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
|
||||
committed && 'text-transparent caret-foreground'
|
||||
)}
|
||||
/>
|
||||
{committed ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-sm"
|
||||
>
|
||||
{/* Baseline-aligned: centring two type sizes leaves the smaller high. */}
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="min-w-0 max-w-[50%] shrink truncate">{label}</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 shrink-[999] truncate text-right text-xs text-muted-foreground"
|
||||
title={detail}
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={translate(
|
||||
'auto.components.new.workspace.RunTargetCombobox.browse',
|
||||
'Browse run targets'
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
inputRef.current?.focus()
|
||||
onToggle()
|
||||
}}
|
||||
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import React from 'react'
|
||||
import { ChevronDown, Cloud, Plus, Server } from 'lucide-react'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { RunTargetRow } from './RunTargetComboboxRow'
|
||||
import {
|
||||
getEphemeralVmLabel,
|
||||
getRecipeDetail,
|
||||
RUN_TARGET_ADD_HOST_KEY,
|
||||
type EphemeralVmRecipeOption
|
||||
} from './run-target-options'
|
||||
import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles'
|
||||
|
||||
const SUBMENU_CONTENT = cn('w-72 p-1', COMBOBOX_POPOVER_SURFACE)
|
||||
|
||||
/** The "Per-Workspace Environment" row and its nested recipe list. */
|
||||
export function RecipesSubmenuRow({
|
||||
open,
|
||||
onOpenChange,
|
||||
armed,
|
||||
optionId,
|
||||
recipes,
|
||||
selectedRecipeId,
|
||||
onArm,
|
||||
onSelectRecipe
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
armed: boolean
|
||||
optionId: string | undefined
|
||||
recipes: readonly EphemeralVmRecipeOption[]
|
||||
selectedRecipeId: string | null
|
||||
onArm: () => void
|
||||
onSelectRecipe: (recipeId: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [hoveredKey, setHoveredKey] = React.useState<string | null>(null)
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<div>
|
||||
<RunTargetRow
|
||||
icon={<Cloud className="size-3.5 shrink-0 text-muted-foreground" />}
|
||||
label={getEphemeralVmLabel()}
|
||||
detail={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.perWorkspaceEnvHint',
|
||||
'Provision an on-demand environment from a recipe'
|
||||
)}
|
||||
armed={armed}
|
||||
current={selectedRecipeId !== null}
|
||||
optionId={optionId}
|
||||
submenu
|
||||
onArm={onArm}
|
||||
onCommit={() => onOpenChange(true)}
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className={SUBMENU_CONTENT}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{/* Why: submenu rows track their own hover — without it they were the
|
||||
only rows in either picker that never highlighted under the pointer. */}
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={getEphemeralVmLabel()}
|
||||
onMouseLeave={() => setHoveredKey(null)}
|
||||
>
|
||||
{recipes.map((recipe) => (
|
||||
<RunTargetRow
|
||||
key={recipe.id}
|
||||
icon={<Cloud className="size-3.5 shrink-0 text-muted-foreground" />}
|
||||
label={recipe.name}
|
||||
detail={getRecipeDetail(recipe)}
|
||||
armed={hoveredKey === recipe.id}
|
||||
current={recipe.id === selectedRecipeId}
|
||||
optionId={undefined}
|
||||
onArm={() => setHoveredKey(recipe.id)}
|
||||
onCommit={() => onSelectRecipe(recipe.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Add host", pinned to the popover edge so it stays reachable in every state —
|
||||
* scrolled, filtered to nothing, or with no hosts at all.
|
||||
*/
|
||||
export function AddHostSubmenuRow({
|
||||
open,
|
||||
onOpenChange,
|
||||
armed,
|
||||
optionId,
|
||||
onArm,
|
||||
onAddSshHost,
|
||||
onAddRemoteServer
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
armed: boolean
|
||||
optionId: string | undefined
|
||||
onArm: () => void
|
||||
onAddSshHost?: () => void
|
||||
onAddRemoteServer?: () => void
|
||||
}): React.JSX.Element {
|
||||
const [hoveredKey, setHoveredKey] = React.useState<string | null>(null)
|
||||
const addHostLabel = translate('auto.components.NewWorkspaceComposerCard.addHost', 'Add host')
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
role="option"
|
||||
id={optionId}
|
||||
aria-selected={armed}
|
||||
// `option` supports aria-haspopup but not aria-expanded.
|
||||
aria-haspopup="menu"
|
||||
data-armed={armed || undefined}
|
||||
data-run-target-add-host="true"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseMove={onArm}
|
||||
onClick={() => onOpenChange(true)}
|
||||
className={cn(
|
||||
'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm',
|
||||
armed && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<Plus className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{addHostLabel}</span>
|
||||
<span className="ml-auto flex shrink-0 items-center">
|
||||
<ChevronDown className="size-3.5 -rotate-90 text-muted-foreground" />
|
||||
</span>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className={SUBMENU_CONTENT}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div role="listbox" aria-label={addHostLabel} onMouseLeave={() => setHoveredKey(null)}>
|
||||
{onAddSshHost ? (
|
||||
<RunTargetRow
|
||||
icon={<Server className="size-3.5 shrink-0 text-muted-foreground" />}
|
||||
label={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addSshHost',
|
||||
'Add SSH host'
|
||||
)}
|
||||
detail={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addSshHostHint',
|
||||
'Use an existing machine over SSH'
|
||||
)}
|
||||
armed={hoveredKey === 'ssh'}
|
||||
current={false}
|
||||
stacked
|
||||
optionId={undefined}
|
||||
onArm={() => setHoveredKey('ssh')}
|
||||
onCommit={onAddSshHost}
|
||||
/>
|
||||
) : null}
|
||||
{onAddRemoteServer ? (
|
||||
<RunTargetRow
|
||||
icon={<Cloud className="size-3.5 shrink-0 text-muted-foreground" />}
|
||||
label={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServer',
|
||||
'Add Remote Orca Server'
|
||||
)}
|
||||
detail={translate(
|
||||
'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServerHint',
|
||||
'Pair another Orca runtime'
|
||||
)}
|
||||
armed={hoveredKey === 'remote'}
|
||||
current={false}
|
||||
stacked
|
||||
optionId={undefined}
|
||||
onArm={() => setHoveredKey('remote')}
|
||||
onCommit={onAddRemoteServer}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { RUN_TARGET_ADD_HOST_KEY }
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
|
||||
import {
|
||||
getAmbiguousProjectOptionIds,
|
||||
rankProjectOptions,
|
||||
sectionProjectOptions,
|
||||
splitDetailForElision
|
||||
} from './project-combobox-matching'
|
||||
|
||||
function project(id: string, displayName: string, detail: string): NewWorkspaceProjectOption {
|
||||
return { kind: 'project', id, projectId: id, displayName, badgeColor: '#111', detail }
|
||||
}
|
||||
|
||||
const orca = project('orca', 'orca', 'stablyai/orca')
|
||||
const relay = project('relay', 'orca-relay', 'stablyai/orca-relay')
|
||||
const gateway = project('gateway', 'api-gateway', 'acme/api-gateway')
|
||||
|
||||
describe('rankProjectOptions', () => {
|
||||
it('ranks a name-prefix match above a mid-name match', () => {
|
||||
const ranked = rankProjectOptions([relay, orca], 'orca', [])
|
||||
expect(ranked[0]?.option.id).toBe('orca')
|
||||
})
|
||||
|
||||
it('matches the detail line when the name does not match', () => {
|
||||
const ranked = rankProjectOptions([gateway], 'acme', [])
|
||||
expect(ranked).toHaveLength(1)
|
||||
expect(ranked[0]?.detailHits.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('matches a scattered subsequence in the name but not in the detail', () => {
|
||||
const hosts = project('infra', 'infra', '3 hosts configured')
|
||||
// "sc" appears scattered in "hosts configured" but detail matching is
|
||||
// substring-only, so this must not match on the detail line.
|
||||
expect(rankProjectOptions([hosts], 'sc', [])).toHaveLength(0)
|
||||
expect(rankProjectOptions([hosts], 'ifa', [])).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('orders an unfiltered list by recency', () => {
|
||||
const ranked = rankProjectOptions([orca, relay, gateway], '', ['gateway', 'relay'])
|
||||
expect(ranked.map((r) => r.option.id)).toEqual(['gateway', 'relay', 'orca'])
|
||||
})
|
||||
|
||||
it('returns nothing for an oversized query rather than scanning it', () => {
|
||||
expect(rankProjectOptions([orca], 'x'.repeat(4096), [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sectionProjectOptions', () => {
|
||||
const many = Array.from({ length: 7 }, (_, i) => project(`p${i}`, `proj-${i}`, `acme/proj-${i}`))
|
||||
|
||||
it('collapses to a single unlabelled list while a query is live', () => {
|
||||
const matches = rankProjectOptions(many, 'proj', [])
|
||||
const sections = sectionProjectOptions(matches, 'proj', ['p3'])
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0]?.heading).toBeNull()
|
||||
})
|
||||
|
||||
it('splits into Recent/Projects once the list is long enough to warrant it', () => {
|
||||
const matches = rankProjectOptions(many, '', ['p3', 'p5'])
|
||||
const sections = sectionProjectOptions(matches, '', ['p3', 'p5'])
|
||||
expect(sections.map((s) => s.heading)).toEqual(['Recent', 'Projects'])
|
||||
expect(sections[0]?.items.map((i) => i.option.id)).toEqual(['p3', 'p5'])
|
||||
})
|
||||
|
||||
it('keeps a short list unsectioned', () => {
|
||||
const matches = rankProjectOptions([orca, relay], '', ['relay'])
|
||||
expect(sectionProjectOptions(matches, '', ['relay'])).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAmbiguousProjectOptionIds', () => {
|
||||
it('flags only ids whose display name repeats', () => {
|
||||
const a = project('a', 'scratch', '~/code/scratch')
|
||||
const b = project('b', 'scratch', '~/src/scratch')
|
||||
const ids = getAmbiguousProjectOptionIds([a, b, orca])
|
||||
expect(ids).toEqual(new Set(['a', 'b']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitDetailForElision', () => {
|
||||
it('keeps the last two segments so sibling paths stay distinguishable', () => {
|
||||
const split = splitDetailForElision('~/Developer/work/acme/monorepo/services/checkout-api')
|
||||
expect(split?.tail).toBe('services/checkout-api')
|
||||
})
|
||||
|
||||
it('leaves short or shallow details alone', () => {
|
||||
expect(splitDetailForElision('stablyai/orca')).toBeNull()
|
||||
expect(splitDetailForElision('3 hosts configured')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
|
||||
import { isNewWorkspaceProjectOptionQueryTooLarge } from '@/lib/new-workspace-project-options'
|
||||
|
||||
export type ScoredProjectOption = {
|
||||
option: NewWorkspaceProjectOption
|
||||
score: number
|
||||
nameHits: readonly number[]
|
||||
detailHits: readonly number[]
|
||||
}
|
||||
|
||||
function substringHits(text: string, query: string): number[] | null {
|
||||
const at = text.toLowerCase().indexOf(query)
|
||||
return at < 0 ? null : Array.from({ length: query.length }, (_, offset) => at + offset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim run, else scattered subsequence. Only names get the loose pass —
|
||||
* a subsequence over a detail line matches "scr" against "3 hosts configured".
|
||||
*/
|
||||
function nameHitsFor(name: string, query: string): number[] | null {
|
||||
const verbatim = substringHits(name, query)
|
||||
if (verbatim) {
|
||||
return verbatim
|
||||
}
|
||||
const haystack = name.toLowerCase()
|
||||
const hits: number[] = []
|
||||
let cursor = 0
|
||||
for (const char of query) {
|
||||
const found = haystack.indexOf(char, cursor)
|
||||
if (found < 0) {
|
||||
return null
|
||||
}
|
||||
hits.push(found)
|
||||
cursor = found + 1
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
function nameScore(name: string, hits: readonly number[]): number {
|
||||
const first = hits[0] ?? 0
|
||||
const contiguous = (hits.at(-1) ?? first) - first === hits.length - 1
|
||||
const boundary = first === 0 || /[^a-z0-9]/i.test(name[first - 1] ?? '')
|
||||
const base = contiguous ? (first === 0 ? 900 : boundary ? 780 : 700) : 420
|
||||
return base - name.length * 0.4
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks options for a query, with recency breaking ties (and ordering the
|
||||
* unfiltered list). `recentIds` is most-recent-first.
|
||||
*/
|
||||
export function rankProjectOptions(
|
||||
options: readonly NewWorkspaceProjectOption[],
|
||||
rawQuery: string,
|
||||
recentIds: readonly string[]
|
||||
): ScoredProjectOption[] {
|
||||
if (isNewWorkspaceProjectOptionQueryTooLarge(rawQuery)) {
|
||||
return []
|
||||
}
|
||||
const query = rawQuery.trim().toLowerCase()
|
||||
const scored: ScoredProjectOption[] = []
|
||||
for (const option of options) {
|
||||
const recentAt = recentIds.indexOf(option.id)
|
||||
const recency = recentAt < 0 ? 0 : 32 - recentAt * 4
|
||||
if (query.length === 0) {
|
||||
scored.push({ option, score: recency, nameHits: [], detailHits: [] })
|
||||
continue
|
||||
}
|
||||
const nameHits = nameHitsFor(option.displayName, query)
|
||||
const detailHits = substringHits(option.detail, query)
|
||||
if (!nameHits && !detailHits) {
|
||||
continue
|
||||
}
|
||||
scored.push({
|
||||
option,
|
||||
score: (nameHits ? nameScore(option.displayName, nameHits) : 260) + recency,
|
||||
nameHits: nameHits ?? [],
|
||||
detailHits: detailHits ?? []
|
||||
})
|
||||
}
|
||||
return scored.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
a.option.displayName.localeCompare(b.option.displayName) ||
|
||||
a.option.detail.localeCompare(b.option.detail)
|
||||
)
|
||||
}
|
||||
|
||||
export type ProjectOptionSection = {
|
||||
key: 'recent' | 'projects' | 'folders' | 'results'
|
||||
heading: string | null
|
||||
items: ScoredProjectOption[]
|
||||
}
|
||||
|
||||
/** Below this a list is scannable, so sections are chrome rather than help. */
|
||||
const SECTION_THRESHOLD = 6
|
||||
const RECENT_LIMIT = 4
|
||||
|
||||
/**
|
||||
* While a query is live the ranking *is* the order, so sections would fight it:
|
||||
* everything collapses into one unlabelled result list.
|
||||
*/
|
||||
export function sectionProjectOptions(
|
||||
matches: readonly ScoredProjectOption[],
|
||||
query: string,
|
||||
recentIds: readonly string[]
|
||||
): ProjectOptionSection[] {
|
||||
if (query.trim() !== '' || matches.length < SECTION_THRESHOLD) {
|
||||
return [{ key: 'results', heading: null, items: [...matches] }]
|
||||
}
|
||||
const recentSet = new Set(
|
||||
recentIds.filter((id) => matches.some((m) => m.option.id === id)).slice(0, RECENT_LIMIT)
|
||||
)
|
||||
const recent = recentIds.flatMap((id) =>
|
||||
recentSet.has(id) ? matches.filter((m) => m.option.id === id) : []
|
||||
)
|
||||
const sections: ProjectOptionSection[] = [
|
||||
{ key: 'recent', heading: 'Recent', items: recent },
|
||||
{
|
||||
key: 'projects',
|
||||
heading: 'Projects',
|
||||
items: matches.filter((m) => m.option.kind === 'project' && !recentSet.has(m.option.id))
|
||||
},
|
||||
{
|
||||
key: 'folders',
|
||||
heading: 'Folders',
|
||||
items: matches.filter((m) => m.option.kind === 'project-group')
|
||||
}
|
||||
]
|
||||
return sections.filter((section) => section.items.length > 0)
|
||||
}
|
||||
|
||||
/** Ids whose displayName repeats — those rows can't be read by name alone. */
|
||||
export function getAmbiguousProjectOptionIds(
|
||||
options: readonly NewWorkspaceProjectOption[]
|
||||
): Set<string> {
|
||||
const counts = new Map<string, number>()
|
||||
for (const option of options) {
|
||||
counts.set(option.displayName, (counts.get(option.displayName) ?? 0) + 1)
|
||||
}
|
||||
return new Set(
|
||||
options.filter((o) => (counts.get(o.displayName) ?? 0) > 1).map((option) => option.id)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A deep path's identity lives in its tail (`…/services/checkout-api`), which is
|
||||
* exactly what a plain truncate throws away — two sibling paths then render
|
||||
* identically. Split so the head can elide while the tail keeps its width.
|
||||
*/
|
||||
export function splitDetailForElision(detail: string): { head: string; tail: string } | null {
|
||||
const segments = detail.split('/')
|
||||
if (segments.length <= 3 || detail.length <= 28) {
|
||||
return null
|
||||
}
|
||||
return { head: segments.slice(0, -2).join('/'), tail: segments.slice(-2).join('/') }
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import type { OrcaHooks } from '../../../../shared/types'
|
||||
import type {
|
||||
NeedsSetupProjectHostOption,
|
||||
ProjectHostSetupOption,
|
||||
ReadyProjectHostSetupOption
|
||||
} from '@/lib/project-host-setup-options'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type EphemeralVmRecipeOption = NonNullable<OrcaHooks['environmentRecipes']>[number]
|
||||
|
||||
export const RUN_TARGET_ADD_HOST_KEY = 'add-host'
|
||||
export const RUN_TARGET_RECIPES_KEY = 'per-workspace-env'
|
||||
|
||||
/** A row in the run-target list. Hosts commit; the last two open a submenu. */
|
||||
export type RunTargetRowModel =
|
||||
| { key: string; kind: 'ready'; option: ReadyProjectHostSetupOption }
|
||||
| { key: string; kind: 'needs-setup'; option: NeedsSetupProjectHostOption }
|
||||
| { key: typeof RUN_TARGET_RECIPES_KEY; kind: 'recipes' }
|
||||
| { key: typeof RUN_TARGET_ADD_HOST_KEY; kind: 'add-host' }
|
||||
|
||||
export function getEphemeralVmLabel(): string {
|
||||
return translate(
|
||||
'auto.components.NewWorkspaceComposerCard.ephemeralVm',
|
||||
'Per-Workspace Environment'
|
||||
)
|
||||
}
|
||||
|
||||
export function getRecipeCommandDisplay(command: string): string {
|
||||
const trimmed = command.trim()
|
||||
const quoted = trimmed.match(/^"([^"]+)"/) ?? trimmed.match(/^'([^']+)'/)
|
||||
return quoted?.[1] ?? trimmed.split(/\s+/)[0] ?? trimmed
|
||||
}
|
||||
|
||||
export function getRecipeDestroyLabel(recipe: EphemeralVmRecipeOption): string {
|
||||
if (recipe.destroyDisabled) {
|
||||
return translate('auto.components.NewWorkspaceComposerCard.destroyDisabled', 'destroy disabled')
|
||||
}
|
||||
if (recipe.destroy) {
|
||||
return translate(
|
||||
'auto.components.NewWorkspaceComposerCard.destroyConfigured',
|
||||
'destroy configured'
|
||||
)
|
||||
}
|
||||
return translate('auto.components.NewWorkspaceComposerCard.noDestroyConfigured', 'no destroy')
|
||||
}
|
||||
|
||||
/** One line under a recipe name: what it runs and whether it tears down. */
|
||||
export function getRecipeDetail(recipe: EphemeralVmRecipeOption): string {
|
||||
return `${getRecipeCommandDisplay(recipe.create)} · ${getRecipeDestroyLabel(recipe)}`
|
||||
}
|
||||
|
||||
function matches(haystack: string, query: string): boolean {
|
||||
return haystack.toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters hosts and recipes by a typed query. The submenu rows survive filtering
|
||||
* only while they still have something to show — "Add host" always survives, so
|
||||
* it stays reachable in every state including no-matches.
|
||||
*/
|
||||
export function buildRunTargetRows({
|
||||
hostOptions,
|
||||
recipes,
|
||||
query,
|
||||
hasAddHost
|
||||
}: {
|
||||
hostOptions: readonly ProjectHostSetupOption[]
|
||||
recipes: readonly EphemeralVmRecipeOption[]
|
||||
query: string
|
||||
hasAddHost: boolean
|
||||
}): { rows: RunTargetRowModel[]; matchedRecipes: EphemeralVmRecipeOption[] } {
|
||||
const trimmed = query.trim().toLowerCase()
|
||||
const hostMatches = (option: ProjectHostSetupOption): boolean =>
|
||||
trimmed === '' ||
|
||||
matches(option.label, trimmed) ||
|
||||
matches(option.detail, trimmed) ||
|
||||
(option.kind === 'ready' && matches(option.path, trimmed))
|
||||
|
||||
const ready = hostOptions.filter(
|
||||
(option): option is ReadyProjectHostSetupOption =>
|
||||
option.kind === 'ready' && hostMatches(option)
|
||||
)
|
||||
const needsSetup = hostOptions.filter(
|
||||
(option): option is NeedsSetupProjectHostOption =>
|
||||
option.kind === 'needs-setup' && hostMatches(option)
|
||||
)
|
||||
const matchedRecipes = recipes.filter(
|
||||
(recipe) =>
|
||||
trimmed === '' ||
|
||||
matches(recipe.name, trimmed) ||
|
||||
matches(getEphemeralVmLabel(), trimmed) ||
|
||||
matches(recipe.description ?? '', trimmed)
|
||||
)
|
||||
|
||||
const rows: RunTargetRowModel[] = [
|
||||
...ready.map((option) => ({ key: `host:${option.id}`, kind: 'ready' as const, option })),
|
||||
...needsSetup.map((option) => ({
|
||||
key: `needs:${option.id}`,
|
||||
kind: 'needs-setup' as const,
|
||||
option
|
||||
}))
|
||||
]
|
||||
if (matchedRecipes.length > 0) {
|
||||
rows.push({ key: RUN_TARGET_RECIPES_KEY, kind: 'recipes' })
|
||||
}
|
||||
if (hasAddHost) {
|
||||
rows.push({ key: RUN_TARGET_ADD_HOST_KEY, kind: 'add-host' })
|
||||
}
|
||||
return { rows, matchedRecipes }
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Shared recipes for the composer's type-ahead pickers, so Project and Run on
|
||||
* can't drift apart.
|
||||
*/
|
||||
|
||||
/** Input-shaped shell without being an `<Input>` — the field wraps a bare input. */
|
||||
export const COMBOBOX_FIELD_SHELL =
|
||||
'flex h-9 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30'
|
||||
|
||||
/**
|
||||
* Opaque and unfaded, unlike the shared popover recipe. These land directly on
|
||||
* the composer dialog, so a translucent fade shows the form underneath
|
||||
* mid-animation and the open reads as a double flash.
|
||||
*/
|
||||
export const COMBOBOX_POPOVER_SURFACE =
|
||||
'bg-[var(--popover)] data-[state=closed]:fade-out-100 data-[state=open]:fade-in-100 dark:bg-[var(--popover)]'
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { orderProjectIdsByRecency } from './use-recent-project-ids'
|
||||
|
||||
function worktree(partial: Partial<Worktree>): Worktree {
|
||||
return {
|
||||
id: `${partial.repoId ?? 'repo'}::${partial.path ?? '/tmp/wt'}`,
|
||||
repoId: partial.repoId ?? 'repo',
|
||||
displayName: 'wt',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
path: '/tmp/wt',
|
||||
branch: 'main',
|
||||
...partial
|
||||
} as Worktree
|
||||
}
|
||||
|
||||
describe('orderProjectIdsByRecency', () => {
|
||||
it('orders projects by their newest workspace, newest first', () => {
|
||||
const ids = orderProjectIdsByRecency([
|
||||
worktree({ projectId: 'alpha', createdAt: 10 }),
|
||||
worktree({ projectId: 'beta', createdAt: 30 }),
|
||||
worktree({ projectId: 'gamma', createdAt: 20 })
|
||||
])
|
||||
expect(ids.filter((id) => !id.includes(':'))).toEqual(['beta', 'gamma', 'alpha'])
|
||||
})
|
||||
|
||||
it('uses the newest workspace per project, not the oldest', () => {
|
||||
const ids = orderProjectIdsByRecency([
|
||||
worktree({ projectId: 'alpha', createdAt: 1 }),
|
||||
worktree({ projectId: 'alpha', createdAt: 99 }),
|
||||
worktree({ projectId: 'beta', createdAt: 50 })
|
||||
])
|
||||
expect(ids[0]).toBe('alpha')
|
||||
})
|
||||
|
||||
it('skips legacy repo-only workspaces that carry no project identity', () => {
|
||||
const ids = orderProjectIdsByRecency([
|
||||
worktree({ projectId: undefined, createdAt: 99 }),
|
||||
worktree({ projectId: 'beta', createdAt: 1 })
|
||||
])
|
||||
expect(ids).toContain('beta')
|
||||
expect(ids).not.toContain(undefined)
|
||||
})
|
||||
|
||||
it('emits the folder-group option id too, so grouped targets resolve', () => {
|
||||
const ids = orderProjectIdsByRecency([worktree({ projectId: 'alpha', createdAt: 5 })])
|
||||
expect(ids).toContain('project-group:alpha')
|
||||
})
|
||||
|
||||
it('treats a missing createdAt as oldest rather than crashing', () => {
|
||||
const ids = orderProjectIdsByRecency([
|
||||
worktree({ projectId: 'alpha' }),
|
||||
worktree({ projectId: 'beta', createdAt: 5 })
|
||||
])
|
||||
expect(ids[0]).toBe('beta')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { useMemo } from 'react'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { useAllWorktrees } from '@/store/selectors'
|
||||
import { NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX } from '@/lib/new-workspace-project-options'
|
||||
|
||||
/**
|
||||
* Most-recently-used project option ids, newest first, derived from when the
|
||||
* user last created a workspace in each project. Orca stores no explicit
|
||||
* per-project recency, and workspace creation is exactly the action this picker
|
||||
* is about to repeat, so it's the honest proxy rather than a new store field.
|
||||
*/
|
||||
export function orderProjectIdsByRecency(worktrees: readonly Worktree[]): string[] {
|
||||
const newestByProject = new Map<string, number>()
|
||||
for (const worktree of worktrees) {
|
||||
const projectId = worktree.projectId
|
||||
// Legacy repo-only workspaces have no projectId and can't be attributed.
|
||||
if (projectId === undefined || projectId === '') {
|
||||
continue
|
||||
}
|
||||
const createdAt = worktree.createdAt ?? 0
|
||||
const seen = newestByProject.get(projectId)
|
||||
if (seen === undefined || createdAt > seen) {
|
||||
newestByProject.set(projectId, createdAt)
|
||||
}
|
||||
}
|
||||
return [...newestByProject.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.flatMap(([projectId]) => [
|
||||
projectId,
|
||||
// Folder-group options carry a prefixed id, so both forms resolve.
|
||||
`${NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX}${projectId}`
|
||||
])
|
||||
}
|
||||
|
||||
export function useRecentProjectIds(): string[] {
|
||||
const worktrees = useAllWorktrees()
|
||||
return useMemo(() => orderProjectIdsByRecency(worktrees), [worktrees])
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useWheelScrollable } from './use-wheel-scrollable'
|
||||
|
||||
/**
|
||||
* The machinery shared by the composer's type-ahead pickers (Project, Run on):
|
||||
* the field is the search box, exactly one row is armed at a time, and Enter
|
||||
* takes it.
|
||||
*
|
||||
* Two subtleties worth keeping:
|
||||
* - Armed is tracked by row *key*, not index, so a list arriving late over SSH
|
||||
* cannot slide a different row under a keypress the user already aimed. The
|
||||
* query it was aimed at rides along, so a newer query drops a stale arm
|
||||
* during render rather than needing a reset effect.
|
||||
* - Closing without committing clears the query, or the field is left showing
|
||||
* text that matches nothing while hiding the committed selection.
|
||||
*/
|
||||
export function useTypeAheadCombobox(deriveRowKeys: (query: string) => readonly string[]): {
|
||||
query: string
|
||||
setQuery: (value: string) => void
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
/** Clears the query and closes — the "back out without committing" path. */
|
||||
close: () => void
|
||||
/** Radix `onOpenChange`: closing always drops an uncommitted query. */
|
||||
handleOpenChange: (next: boolean) => void
|
||||
/** Row keys for the current query, as returned by `deriveRowKeys`. */
|
||||
rowKeys: readonly string[]
|
||||
armedKey: string | null
|
||||
arm: (key: string) => void
|
||||
/** Moves the arm by `step` rows, clamped to the ends. */
|
||||
moveArm: (step: number) => void
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
listId: string
|
||||
/** Ref callback for the scroll pane; also restores wheel scrolling. */
|
||||
setListNode: (node: HTMLDivElement | null) => void
|
||||
} {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [armed, setArmed] = useState<{ key: string; query: string } | null>(null)
|
||||
const rowKeys = deriveRowKeys(query)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
// react-remove-scroll (Radix Dialog) cancels wheel events for this portaled
|
||||
// pane, so the list has to scroll itself.
|
||||
const { ref: listRef, setNode: setListNode } = useWheelScrollable<HTMLDivElement>()
|
||||
const listId = React.useId()
|
||||
|
||||
// A stale arm (aimed at an earlier query) falls back to the first row.
|
||||
const armedKey = armed !== null && armed.query === query ? armed.key : null
|
||||
const armedIndex = Math.max(armedKey === null ? -1 : rowKeys.indexOf(armedKey), 0)
|
||||
const resolvedKey = rowKeys[armedIndex] ?? null
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
listRef.current?.querySelector('[data-armed="true"]')?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}, [listRef, open, armedIndex, rowKeys.length])
|
||||
|
||||
const arm = useCallback(
|
||||
// `query` is read at call time; the closure refreshes each render.
|
||||
(key: string) => setArmed({ key, query }),
|
||||
[query]
|
||||
)
|
||||
|
||||
const moveArm = useCallback(
|
||||
(step: number): void => {
|
||||
const next = rowKeys[Math.min(Math.max(armedIndex + step, 0), rowKeys.length - 1)]
|
||||
if (next !== undefined) {
|
||||
setArmed({ key: next, query })
|
||||
}
|
||||
},
|
||||
[armedIndex, query, rowKeys]
|
||||
)
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}, [])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean): void => {
|
||||
if (next) {
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
close()
|
||||
},
|
||||
[close]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
query,
|
||||
setQuery,
|
||||
open,
|
||||
setOpen,
|
||||
close,
|
||||
handleOpenChange,
|
||||
rowKeys,
|
||||
armedKey: resolvedKey,
|
||||
arm,
|
||||
moveArm,
|
||||
inputRef,
|
||||
listId,
|
||||
setListNode
|
||||
}),
|
||||
[arm, close, handleOpenChange, listId, moveArm, open, query, resolvedKey, rowKeys, setListNode]
|
||||
)
|
||||
}
|
||||
|
||||
/** True when an event target lies inside the control marked by `rootAttribute`. */
|
||||
export function isWithinComboboxRoot(target: EventTarget | null, rootAttribute: string): boolean {
|
||||
return target instanceof Element && target.closest(`[${rootAttribute}="true"]`) !== null
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
|
||||
|
||||
/**
|
||||
* Restores mouse-wheel scrolling for a scroll container inside a portaled
|
||||
* popover, and hands back the node.
|
||||
*
|
||||
* Radix Dialog applies react-remove-scroll, which calls preventDefault() on
|
||||
* wheel events for elements outside the dialog's DOM tree — the scrollbar
|
||||
* renders and drags fine, but the wheel does nothing. A non-passive listener on
|
||||
* the container scrolls it manually instead. `ui/command`'s CommandList carries
|
||||
* the same shim; this is the equivalent for a plain scroll pane.
|
||||
*/
|
||||
export function useWheelScrollable<T extends HTMLElement>(): {
|
||||
ref: MutableRefObject<T | null>
|
||||
setNode: (node: T | null) => void
|
||||
} {
|
||||
const ref = useRef<T | null>(null)
|
||||
const detachRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const setNode = useCallback((node: T | null): void => {
|
||||
detachRef.current?.()
|
||||
detachRef.current = null
|
||||
ref.current = node
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (node.scrollHeight <= node.clientHeight) {
|
||||
return
|
||||
}
|
||||
const delta =
|
||||
event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||
? event.deltaY * 16
|
||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
||||
? event.deltaY * node.clientHeight
|
||||
: event.deltaY
|
||||
const max = node.scrollHeight - node.clientHeight
|
||||
const next = Math.max(0, Math.min(max, node.scrollTop + delta))
|
||||
if (next === node.scrollTop) {
|
||||
// At an edge: let the event bubble so an ancestor can take the scroll.
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
node.scrollTop = next
|
||||
}
|
||||
node.addEventListener('wheel', onWheel, { passive: false })
|
||||
detachRef.current = () => node.removeEventListener('wheel', onWheel)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => detachRef.current?.(), [])
|
||||
|
||||
return { ref, setNode }
|
||||
}
|
||||
|
|
@ -11411,9 +11411,17 @@
|
|||
"placeholder": "Choose host"
|
||||
},
|
||||
"ProjectCombobox": {
|
||||
"search": "Search projects...",
|
||||
"empty": "No projects match your search.",
|
||||
"addProject": "Add a new project"
|
||||
"addProject": "Add a new project",
|
||||
"label": "Project",
|
||||
"browse": "Browse projects",
|
||||
"listLabel": "Projects",
|
||||
"noProjects": "No projects yet."
|
||||
},
|
||||
"RunTargetCombobox": {
|
||||
"listLabel": "Run targets",
|
||||
"label": "Run on",
|
||||
"browse": "Browse run targets"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11388,9 +11388,17 @@
|
|||
"placeholder": "Elegir host"
|
||||
},
|
||||
"ProjectCombobox": {
|
||||
"search": "Buscar proyectos...",
|
||||
"empty": "Ningún proyecto coincide con tu búsqueda.",
|
||||
"addProject": "Add a new project"
|
||||
"addProject": "Add a new project",
|
||||
"label": "Project",
|
||||
"browse": "Browse projects",
|
||||
"listLabel": "Projects",
|
||||
"noProjects": "No projects yet."
|
||||
},
|
||||
"RunTargetCombobox": {
|
||||
"listLabel": "Run targets",
|
||||
"label": "Run on",
|
||||
"browse": "Browse run targets"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11388,9 +11388,17 @@
|
|||
"placeholder": "Choose host"
|
||||
},
|
||||
"ProjectCombobox": {
|
||||
"search": "Search projects...",
|
||||
"empty": "No projects match your search.",
|
||||
"addProject": "Add a new project"
|
||||
"addProject": "Add a new project",
|
||||
"label": "Project",
|
||||
"browse": "Browse projects",
|
||||
"listLabel": "Projects",
|
||||
"noProjects": "No projects yet."
|
||||
},
|
||||
"RunTargetCombobox": {
|
||||
"listLabel": "Run targets",
|
||||
"label": "Run on",
|
||||
"browse": "Browse run targets"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11388,9 +11388,17 @@
|
|||
"placeholder": "호스트 선택"
|
||||
},
|
||||
"ProjectCombobox": {
|
||||
"search": "프로젝트 검색...",
|
||||
"empty": "검색과 일치하는 프로젝트가 없습니다.",
|
||||
"addProject": "Add a new project"
|
||||
"addProject": "Add a new project",
|
||||
"label": "Project",
|
||||
"browse": "Browse projects",
|
||||
"listLabel": "Projects",
|
||||
"noProjects": "No projects yet."
|
||||
},
|
||||
"RunTargetCombobox": {
|
||||
"listLabel": "Run targets",
|
||||
"label": "Run on",
|
||||
"browse": "Browse run targets"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11388,9 +11388,17 @@
|
|||
"placeholder": "选择主机"
|
||||
},
|
||||
"ProjectCombobox": {
|
||||
"search": "搜索项目...",
|
||||
"empty": "没有匹配搜索的项目。",
|
||||
"addProject": "Add a new project"
|
||||
"addProject": "Add a new project",
|
||||
"label": "Project",
|
||||
"browse": "Browse projects",
|
||||
"listLabel": "Projects",
|
||||
"noProjects": "No projects yet."
|
||||
},
|
||||
"RunTargetCombobox": {
|
||||
"listLabel": "Run targets",
|
||||
"label": "Run on",
|
||||
"browse": "Browse run targets"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue