fix(ssh): draw every host list through one bounded, searchable host picker, so a 13k-host ssh config can't crash any of them (#31426)

* fix(ssh): draw every host list through one bounded, searchable host picker

A generated ~/.ssh/config can import 13k+ SSH hosts, and several surfaces drew a
row per host: Settings > General workspace directory 'Apply to', Notifications >
Machines, the sidebar and Agents Hosts submenu, the composer run-target picker,
the Add Project host selector and remote step, the skill-install machine pickers,
Settings > Servers SSH host picker, repository 'Add to another host' and the
automation host field and filter.

They now share components/host-picker: boundHostList (pinned hosts plus a ranked,
capped match list), HostCombobox for single choice (a Command in a Popover that
mounts nothing until opened, replacing Selects that mount every item), and
useBoundedHostRows with HostListSearchField and HostListOverflowNote for
checklists. A host-list-render ratchet stops new JSX from mapping straight over a
host collection.

* fix(ssh): host lists keep room for search results, menu search is keyboard-reachable

- boundHostList gives pinned hosts and other matches separate limits, and narrows
  pinned hosts by the query, so a large selection never hides what the user types.
- In a dropdown, the host search is a menu item: arrow keys reach it, focus moves
  into the input, and arrows leave it again.
- The Add Project host picker hides its setup actions while searching, so Enter
  picks the matching host.

* fix(ssh): re-picking the current host in a host picker is not a change

The Selects these pickers replaced suppressed same-value changes; the skill
install form clears its workspace and target on a machine change.

* chore(ci): let the shared ratchet runner pick up the host-list render ratchet

main now discovers every check-*-ratchet.mjs through run-ratchets.mjs, so the
separate workflow step would run it twice.

* fix(ssh): host lists rank pinned matches and keep input order; label and scroll fixes

- boundHostList ranks pinned and other hosts by the same match order before
  cutting each to its limit, then keeps input order among equal ranks, so a
  muted or checked exact match is always reachable and clicking a row never
  moves it.
- The Servers SSH host picker is named by its form label again.
- The sidebar Hosts submenu scrolls its host rows instead of running off screen.
- The composer's overflow note sits outside the listbox.
This commit is contained in:
Neil
2026-10-20 23:33:36 -07:00
committed by GitHub
parent 505d6719c0
commit 2bf77e605b
35 changed files with 1228 additions and 239 deletions
+16
View File
@@ -0,0 +1,16 @@
# JSX mapped straight over a host collection, per file, outside components/host-picker.
# This is a RATCHET: counts may only go DOWN. Draw host lists with HostCombobox or
# useBoundedHostRows. Prune after a fix: pnpm check:host-list-render-ratchet --prune
1 src/renderer/src/components/automations/AutomationRunsDashboard.tsx
1 src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx
1 src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx
1 src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
1 src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx
1 src/renderer/src/components/settings/BrowserSshWorkspaceRoutingSetting.tsx
1 src/renderer/src/components/settings/QuickCommandsToolbar.tsx
1 src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx
1 src/renderer/src/components/sidebar/AddRemoteHostSshConfigPicker.tsx
4 src/renderer/src/components/status-bar/SshStatusSegment.tsx
1 src/renderer/src/components/status-bar/resource-usage-popover-body.tsx
1 src/renderer/src/components/tab-bar/TabBarQuickCommandAddActions.tsx
2 src/renderer/src/components/terminal-pane/TerminalQuickCommandsSubmenu.tsx
@@ -0,0 +1,69 @@
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { readTrackedSources, runCountRatchet } from './ratchet-baseline.mjs'
// Ratchet on JSX drawn straight from a host collection (`hosts.map((h) => <Row …/>)`). A generated
// ~/.ssh/config can import >10k SSH hosts, and drawing a row per host crashed the renderer. Host
// lists go through src/renderer/src/components/host-picker (bounded, searchable). Counts per file
// may only go down.
const BASELINE_PATH = 'config/host-list-render-baseline.txt'
const SCAN_ROOT = 'src/renderer/src'
const PRIMITIVE_DIR = 'src/renderer/src/components/host-picker/'
const HEADER = [
'# JSX mapped straight over a host collection, per file, outside components/host-picker.',
'# This is a RATCHET: counts may only go DOWN. Draw host lists with HostCombobox or',
'# useBoundedHostRows. Prune after a fix: pnpm check:host-list-render-ratchet --prune'
]
// `<name>.map(` where the name says host/target/ssh/machine, whose callback returns JSX directly.
const HOST_COLLECTION_JSX_MAP =
/\b[\w.]*(?:[hH]ost|[tT]arget|[sS]sh|SSH|[mM]achine)\w*\.map\(\s*(?:\([^()]*\)|\w+)\s*=>\s*(?:\(\s*)?</g
const COMMENT = /\/\*[\s\S]*?\*\/|(?<![:'"`\\])\/\/.*$/gm
export function isScannedPath(rel) {
return (
rel.startsWith(`${SCAN_ROOT}/`) &&
rel.endsWith('.tsx') &&
!/\.(test|spec)\.tsx$/.test(rel) &&
!rel.startsWith(PRIMITIVE_DIR)
)
}
export function countHostCollectionJsxMaps(sourceText) {
return sourceText.replace(COMMENT, ' ').match(HOST_COLLECTION_JSX_MAP)?.length ?? 0
}
export function collectHostListRenderCounts(root = process.cwd()) {
const counts = new Map()
for (const [rel, source] of readTrackedSources(root, [SCAN_ROOT], isScannedPath)) {
const count = countHostCollectionJsxMaps(source)
if (count > 0) {
counts.set(rel, count)
}
}
return counts
}
export function main(root = process.cwd(), arg = undefined) {
return runCountRatchet(
{
name: 'host-list-render',
baselinePath: BASELINE_PATH,
header: HEADER,
collect: collectHostListRenderCounts,
unit: 'host collection JSX map(s)',
fix: [
'A host list can hold every configured SSH host (10k+ for a generated ~/.ssh/config).',
'Draw it through src/renderer/src/components/host-picker: HostCombobox for a single',
'choice, useBoundedHostRows (+ HostListSearchField, HostListOverflowNote) for a checklist.'
]
},
root,
arg
)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(main(process.cwd(), process.argv[2]))
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { countHostCollectionJsxMaps, isScannedPath } from './check-host-list-render-ratchet.mjs'
describe('countHostCollectionJsxMaps', () => {
it('counts JSX drawn straight from a host collection', () => {
expect(
countHostCollectionJsxMaps(
'{hostOptions.map((host) => (\n <SelectItem key={host.id} value={host.id} />\n))}'
)
).toBe(1)
expect(
countHostCollectionJsxMaps('{props.sshConnections.map((c) => <Row key={c.id} />)}')
).toBe(1)
expect(countHostCollectionJsxMaps('{targets.map(t => <li key={t.id} />)}')).toBe(1)
})
it('ignores bounded rows, data maps and comments', () => {
expect(countHostCollectionJsxMaps('{rows.shown.map((host) => <Row key={host.id} />)}')).toBe(0)
expect(countHostCollectionJsxMaps('{targetRows.shown.map((t) => <Row key={t.id} />)}')).toBe(0)
expect(countHostCollectionJsxMaps('new Map(hostOptions.map((host) => [host.id, host]))')).toBe(
0
)
expect(countHostCollectionJsxMaps('hosts.map((host) => ({ id: host.id }))')).toBe(0)
expect(countHostCollectionJsxMaps('// hosts.map((h) => <Row />)')).toBe(0)
})
})
describe('isScannedPath', () => {
it('scans renderer components outside the host-picker primitive', () => {
expect(isScannedPath('src/renderer/src/components/settings/A.tsx')).toBe(true)
expect(isScannedPath('src/renderer/src/components/host-picker/HostCombobox.tsx')).toBe(false)
expect(isScannedPath('src/renderer/src/components/settings/A.test.tsx')).toBe(false)
expect(isScannedPath('src/renderer/src/components/settings/a.ts')).toBe(false)
expect(isScannedPath('src/main/a.tsx')).toBe(false)
})
})
+2 -1
View File
@@ -14,7 +14,7 @@
"audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src",
"test:perf:contracts": "vitest run --config config/vitest.performance.config.ts",
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:unproven-pty-retirement-ratchet && pnpm run check:id-only-store-write-ratchet && pnpm run check:repo-host-read-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run check:mobile-shared-dependency-versions && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports",
"lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:unproven-pty-retirement-ratchet && pnpm run check:id-only-store-write-ratchet && pnpm run check:repo-host-read-ratchet && pnpm run check:host-list-render-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run check:mobile-shared-dependency-versions && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
@@ -39,6 +39,7 @@
"check:ratchets": "node config/scripts/run-ratchets.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs",
"check:host-list-render-ratchet": "node config/scripts/check-host-list-render-ratchet.mjs",
"check:path-literal-ratchet": "node config/scripts/check-path-literal-ratchet.mjs",
"check:legacy-relay-fence-ratchet": "node config/scripts/check-legacy-relay-fence-ratchet.mjs",
"check:unproven-pty-retirement-ratchet": "node config/scripts/check-unproven-pty-retirement-ratchet.mjs",
@@ -1,13 +1,5 @@
import React from 'react'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { HostCombobox } from '@/components/host-picker/HostCombobox'
import { translate } from '@/i18n/i18n'
import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges'
import { Field } from './automation-page-parts'
@@ -41,36 +33,33 @@ export function AutomationDestinationField({
return (
<Field label={label} labelClassName={labelClassName}>
<Select value={selected?.stableKey ?? ''} onValueChange={control.onSelect}>
<SelectTrigger aria-label={label} className="h-9 w-full min-w-0">
<SelectValue
placeholder={translate(
'auto.components.automations.createDestination.placeholder',
'Select a host'
)}
/>
</SelectTrigger>
<SelectContent>
{groups.map((group) => (
<SelectGroup key={group.authorityKey} data-authority-key={group.authorityKey}>
<SelectLabel>{group.authorityLabel}</SelectLabel>
{group.entries.map((entry) => (
<SelectItem
key={entry.stableKey}
value={entry.stableKey}
disabled={!automationCreateHostEligible(entry)}
data-host-stable-key={entry.stableKey}
>
<span className="flex min-w-0 items-center gap-2">
<AutomationHostLabel entry={entry} className="min-w-0" />
<AutomationHostStatusBadges entry={entry} />
</span>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
<HostCombobox
value={selected?.stableKey ?? ''}
onChange={control.onSelect}
ariaLabel={label}
placeholder={translate(
'auto.components.automations.createDestination.placeholder',
'Select a host'
)}
className="min-w-0"
options={groups.flatMap((group) =>
group.entries.map((entry) => ({
id: entry.stableKey,
label: entry.label,
group: group.authorityLabel,
disabled: !automationCreateHostEligible(entry),
content: (
<span
className="flex min-w-0 flex-1 items-center gap-2"
data-host-stable-key={entry.stableKey}
>
<AutomationHostLabel entry={entry} className="min-w-0" />
<AutomationHostStatusBadges entry={entry} />
</span>
)
}))
)}
/>
{selected && control.projects.length === 0 ? (
// Otherwise Create is disabled for an empty project list with nothing said.
<p className="text-xs text-destructive" data-testid="automation-create-no-projects">
@@ -19,6 +19,9 @@ import {
import { AgentIcon, getAgentCatalog } from '@/lib/agent-catalog'
import { searchAgentPickerEntries } from '@/lib/agent-picker-search'
import { translate } from '@/i18n/i18n'
import { useBoundedHostRows } from '@/components/host-picker/use-bounded-host-rows'
import { HostListSearchField } from '@/components/host-picker/HostListSearchField'
import { HostListOverflowNote } from '@/components/host-picker/HostListOverflowNote'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import {
countAutomationListFilters,
@@ -138,6 +141,41 @@ export function AutomationListFilterPills({
)
}
/** One row per catalog host, bounded and searchable; selected hosts always stay listed. */
function AutomationHostFilterRows({
entries,
selectedHostKeys,
onToggle
}: {
entries: readonly AutomationHostCatalogEntry[]
selectedHostKeys: readonly string[]
onToggle: (stableKey: string) => void
}): React.JSX.Element {
const selected = new Set(selectedHostKeys)
const rows = useBoundedHostRows(
entries.map((entry) => ({ id: entry.stableKey, label: entry.label })),
selected
)
return (
<>
{rows.searchable ? (
<HostListSearchField value={rows.query} onChange={rows.setQuery} inMenu />
) : null}
{rows.shown.map((row) => (
<DropdownMenuCheckboxItem
key={row.id}
checked={selected.has(row.id)}
onCheckedChange={() => onToggle(row.id)}
onSelect={(event) => event.preventDefault()}
>
{row.label}
</DropdownMenuCheckboxItem>
))}
<HostListOverflowNote hiddenCount={rows.hiddenCount} />
</>
)
}
export function AutomationListFilterMenu({
filter,
onChange,
@@ -211,16 +249,11 @@ export function AutomationListFilterMenu({
>
{translate('auto.components.automations.hostPicker.allHosts', 'All hosts')}
</DropdownMenuCheckboxItem>
{hostEntries.map((entry) => (
<DropdownMenuCheckboxItem
key={entry.stableKey}
checked={selectedHostKeys.includes(entry.stableKey)}
onCheckedChange={() => toggleHost(entry.stableKey)}
onSelect={(event) => event.preventDefault()}
>
{entry.label}
</DropdownMenuCheckboxItem>
))}
<AutomationHostFilterRows
entries={hostEntries}
selectedHostKeys={selectedHostKeys}
onToggle={toggleHost}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
@@ -0,0 +1,149 @@
import { useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { boundHostList } from './bounded-host-list'
import { HostListOverflowNote } from './HostListOverflowNote'
import { noHostsMatchLabel, searchHostsLabel } from './host-picker-copy'
export type HostComboboxOption = {
id: string
/** What search matches and the trigger shows. */
label: string
/** Secondary text after the label, such as "SSH" or "disconnected". */
detail?: string
/** Replaces label and detail in the result row. */
content?: React.ReactNode
/** Heading the row is listed under; rows keep their input order within it. */
group?: string
disabled?: boolean
}
function groupOptions(
options: readonly HostComboboxOption[]
): { heading: string | undefined; options: HostComboboxOption[] }[] {
const groups = new Map<string | undefined, HostComboboxOption[]>()
for (const option of options) {
const group = groups.get(option.group) ?? []
group.push(option)
groups.set(option.group, group)
}
return [...groups].map(([heading, grouped]) => ({ heading, options: grouped }))
}
/**
* Single choice among hosts. Replaces a Select, which mounts an item per option even while closed:
* this mounts nothing until opened, then a bounded, searchable result list.
*/
export function HostCombobox({
id,
value,
onChange,
options,
placeholder,
ariaLabel,
size = 'default',
className,
disabled
}: {
id?: string
value: string
onChange: (id: string) => void
options: readonly HostComboboxOption[]
placeholder?: string
ariaLabel?: string
size?: 'xs' | 'sm' | 'default'
className?: string
disabled?: boolean
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const selected = options.find((option) => option.id === value)
// Why pinned only before typing: the current choice stays visible without crowding results.
const { shown, hiddenCount } = open
? boundHostList(options, { query, pinnedIds: query === '' ? new Set([value]) : undefined })
: { shown: [], hiddenCount: 0 }
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (next) {
setQuery('')
}
}}
>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
size={size}
role="combobox"
aria-expanded={open}
aria-label={ariaLabel}
disabled={disabled}
className={cn('w-full justify-between', className)}
>
<span className="min-w-0 truncate">
{selected ? selected.label : (placeholder ?? '')}
</span>
<ChevronDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[min(20rem,calc(100vw-1rem))]">
<Command shouldFilter={false} density="menu" label={searchHostsLabel()}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder={searchHostsLabel()}
aria-label={searchHostsLabel()}
/>
<CommandList>
<CommandEmpty>{noHostsMatchLabel()}</CommandEmpty>
{groupOptions(shown).map((group) => (
<CommandGroup key={group.heading ?? ''} heading={group.heading}>
{group.options.map((option) => (
<CommandItem
key={option.id}
value={option.id}
selection="palette"
disabled={option.disabled}
onSelect={() => {
setOpen(false)
// Why: like a Select, re-picking the current host is not a change.
if (option.id !== value) {
onChange(option.id)
}
}}
>
{option.content ?? (
<>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.detail ? (
<span className="shrink-0 text-muted-foreground">{option.detail}</span>
) : null}
</>
)}
{option.id === value ? <Check className="size-3.5 shrink-0" /> : null}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
<HostListOverflowNote hiddenCount={hiddenCount} />
</Command>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,17 @@
import { moreHostsLabel } from './host-picker-copy'
/** Says how many hosts a bounded list left out and that searching reaches them. */
export function HostListOverflowNote({
hiddenCount
}: {
hiddenCount: number
}): React.JSX.Element | null {
if (hiddenCount <= 0) {
return null
}
return (
<div className="border-t border-border px-3 py-2 text-xs text-muted-foreground">
{moreHostsLabel(hiddenCount)}
</div>
)
}
@@ -0,0 +1,98 @@
import { useRef } from 'react'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { searchHostsLabel } from './host-picker-copy'
const MENU_ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
/** Moves focus from the search item to the menu item before or after it. */
function focusAdjacentMenuItem(from: HTMLElement, step: 1 | -1): void {
const menu = from.closest('[role="menu"]')
const item = from.closest<HTMLElement>(MENU_ITEM_SELECTOR)
if (!menu || !item) {
return
}
const items = Array.from(menu.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR)).filter(
(candidate) => !candidate.hasAttribute('data-disabled')
)
items[items.indexOf(item) + step]?.focus()
}
function HostListSearchInput({
value,
onChange,
inputRef,
onKeyDown
}: {
value: string
onChange: (value: string) => void
inputRef?: React.RefObject<HTMLInputElement | null>
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
}): React.JSX.Element {
return (
<Input
ref={inputRef}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
placeholder={searchHostsLabel()}
aria-label={searchHostsLabel()}
className="h-7"
/>
)
}
/**
* Search box for a bounded host checklist. Inside a dropdown menu it is a menu item, so arrow keys
* reach it like any row; focusing the item hands focus to the input, and arrows leave it again.
*/
export function HostListSearchField({
value,
onChange,
inMenu = false
}: {
value: string
onChange: (value: string) => void
inMenu?: boolean
}): React.JSX.Element {
const inputRef = useRef<HTMLInputElement>(null)
if (!inMenu) {
return (
<div className="p-1">
<HostListSearchInput value={value} onChange={onChange} />
</div>
)
}
return (
<DropdownMenuItem
onSelect={(event) => event.preventDefault()}
onFocus={(event) => {
if (event.target === event.currentTarget) {
inputRef.current?.focus()
}
}}
>
<HostListSearchInput
value={value}
onChange={onChange}
inputRef={inputRef}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) {
event.stopPropagation()
return
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
event.stopPropagation()
focusAdjacentMenuItem(event.currentTarget, event.key === 'ArrowDown' ? 1 : -1)
return
}
// Why: letters, Space and Enter belong to the input, not the menu's typeahead or select.
if (event.key !== 'Escape' && event.key !== 'Tab') {
event.stopPropagation()
}
}}
/>
</DropdownMenuItem>
)
}
@@ -0,0 +1,97 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem
} from '@/components/ui/dropdown-menu'
import { boundHostList } from './bounded-host-list'
import { HostListSearchField } from './HostListSearchField'
function host(label: string): { id: string; label: string } {
return { id: label, label }
}
afterEach(() => {
cleanup()
})
describe('boundHostList', () => {
it('ranks an exact name first, then prefixes, then substrings or search text', () => {
const hosts = [
host('east-prod'),
host('prod-2'),
{ ...host('db'), searchText: 'prod database' },
host('PROD')
]
expect(boundHostList(hosts, { query: ' prod ' }).shown.map((h) => h.id)).toEqual([
'PROD',
'prod-2',
'east-prod',
'db'
])
})
it('gives matches their own limit so a full pinned set never hides a search result', () => {
const hosts = Array.from({ length: 80 }, (_, i) => host(`h-${i}`))
const pinnedIds = new Set(hosts.slice(0, 60).map((h) => h.id))
const { shown, hiddenCount } = boundHostList(hosts, { query: 'h-79', pinnedIds, limit: 50 })
expect(shown.map((h) => h.id)).toEqual(['h-79'])
expect(hiddenCount).toBe(0)
const all = boundHostList(hosts, { pinnedIds, limit: 50 })
expect(all.shown).toHaveLength(70)
expect(all.hiddenCount).toBe(10)
})
it('ranks pinned matches too, so an exact pinned name survives a long pinned set', () => {
const hosts = [...Array.from({ length: 60 }, (_, i) => host(`node-${i}-prod`)), host('prod')]
const pinnedIds = new Set(hosts.map((h) => h.id))
const { shown } = boundHostList(hosts, { query: 'prod', pinnedIds, limit: 50 })
expect(shown[0]?.id).toBe('prod')
})
it('keeps input order when not searching, so a row never jumps when it is pinned', () => {
const { shown } = boundHostList([host('local'), host('a'), host('b')], {
pinnedIds: new Set(['b'])
})
expect(shown.map((h) => h.id)).toEqual(['local', 'a', 'b'])
})
it('narrows pinned hosts by the query too', () => {
const { shown } = boundHostList([host('a1'), host('b1')], {
query: 'b',
pinnedIds: new Set(['a1'])
})
expect(shown.map((h) => h.id)).toEqual(['b1'])
})
})
describe('HostListSearchField in a menu', () => {
it('is reached and left with arrow keys, and keeps letters out of the menu', () => {
const onChange = vi.fn()
render(
<DropdownMenu open>
<DropdownMenuContent>
<DropdownMenuItem>All hosts</DropdownMenuItem>
<HostListSearchField value="" onChange={onChange} inMenu />
<DropdownMenuCheckboxItem checked={false}>node-1</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
)
const search = screen.getByRole('textbox', { name: 'Search hosts…' })
const searchItem = search.closest('[role="menuitem"]')
expect(searchItem).toBeTruthy()
fireEvent.focus(searchItem!)
expect(document.activeElement).toBe(search)
fireEvent.keyDown(search, { key: 'n' })
fireEvent.change(search, { target: { value: 'n' } })
expect(onChange).toHaveBeenCalledWith('n')
fireEvent.keyDown(search, { key: 'ArrowDown' })
expect(document.activeElement?.textContent).toBe('node-1')
search.focus()
fireEvent.keyDown(search, { key: 'ArrowUp' })
expect(document.activeElement?.textContent).toBe('All hosts')
})
})
@@ -0,0 +1,54 @@
// Why: a generated ~/.ssh/config can import >10k hosts; a row each made one list cost GBs of DOM.
export const HOST_LIST_ROW_LIMIT = 50
export type BoundedHostList<T> = {
shown: T[]
/** Hosts matching the query that the limits left out. */
hiddenCount: number
}
/** 0 exact name, 1 name prefix, 2 substring of the name or `searchText`; null for no match. */
function matchRank(host: { label: string; searchText?: string }, needle: string): number | null {
const label = host.label.toLowerCase()
if (label === needle) {
return 0
}
if (label.startsWith(needle)) {
return 1
}
if (label.includes(needle) || host.searchText?.toLowerCase().includes(needle)) {
return 2
}
return null
}
/**
* The one choke point for drawing a list of hosts. Matches are ranked exact name, then prefix, then
* substring, so typing a host's full name always reaches it. Pinned hosts (current value, checked or
* muted ones) get their own `limit` so a long pinned set never crowds out other results, and both
* pools are cut by rank. Rows keep their input order (ties included), so clicking a row never moves it.
*/
export function boundHostList<T extends { id: string; label: string; searchText?: string }>(
hosts: readonly T[],
options: { query?: string; pinnedIds?: ReadonlySet<string>; limit?: number } = {}
): BoundedHostList<T> {
const { pinnedIds, limit = HOST_LIST_ROW_LIMIT } = options
const needle = (options.query ?? '').trim().toLowerCase()
const ranked: { host: T; rank: number; index: number }[] = []
hosts.forEach((host, index) => {
const rank = matchRank(host, needle)
if (rank !== null) {
ranked.push({ host, rank, index })
}
})
// Stable sort: equal ranks stay in input order.
ranked.sort((a, b) => a.rank - b.rank)
const pinned = ranked.filter((entry) => pinnedIds?.has(entry.host.id) === true)
const others = ranked.filter((entry) => pinnedIds?.has(entry.host.id) !== true)
const kept = [...pinned.slice(0, limit), ...others.slice(0, limit)]
kept.sort((a, b) => a.rank - b.rank || a.index - b.index)
return {
shown: kept.map((entry) => entry.host),
hiddenCount: Math.max(0, pinned.length - limit) + Math.max(0, others.length - limit)
}
}
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SidebarHostOption } from '../sidebar/sidebar-host-options'
import { HOST_LIST_ROW_LIMIT } from './bounded-host-list'
import { getDefaultSettings } from '../../../../shared/constants'
// Why 13,692: the host count from the generated ~/.ssh/config in the renderer crash this guards.
const HOST_COUNT = 13_692
const NODE_BUDGET = 3_000
const names = Array.from({ length: HOST_COUNT }, (_, i) => `node-${String(i).padStart(5, '0')}`)
const lastName = names[HOST_COUNT - 1]!
const hostOptions = names.map((name, i): SidebarHostOption => ({
id: `ssh:ssh-${i}`,
label: name,
detail: '',
kind: 'ssh',
health: 'disconnected',
presence: 'configured'
}))
// Why a pass-through menu: Radix only mounts sub-menu content after pointer or keyboard entry.
vi.mock('@/components/ui/dropdown-menu', () => {
const Pass = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>
return {
DropdownMenuSub: Pass,
DropdownMenuSubTrigger: Pass,
DropdownMenuSubContent: Pass,
DropdownMenuItem: ({ children }: { children?: React.ReactNode }) => (
<div role="menuitem">{children}</div>
),
DropdownMenuCheckboxItem: ({ children }: { children?: React.ReactNode }) => (
<div role="menuitemcheckbox">{children}</div>
)
}
})
vi.mock('../sidebar/use-sidebar-host-scope-options', () => ({
useSidebarHostScopeOptions: () => ({ hostOptions, hostScopeOptions: [] })
}))
vi.mock('../settings/use-notification-source-options', () => ({
useNotificationSourceOptions: () => hostOptions
}))
const { SidebarHostScopeMenuSection } = await import('../sidebar/SidebarHostScopeMenuSection')
const { NotificationHostToggles } = await import('../settings/NotificationHostToggles')
const { WorkspaceDirectorySetting } = await import('../settings/WorkspaceDirectorySetting')
function nodeCount(): number {
return document.body.getElementsByTagName('*').length
}
afterEach(() => {
cleanup()
})
describe('host menus and settings with a 13,692-host ssh config', () => {
it('sidebar Hosts submenu keeps a filter’s checked hosts and bounds the rest', () => {
const lastId = hostOptions[HOST_COUNT - 1]!.id
render(
<SidebarHostScopeMenuSection
hostVisibilityLabel="1 host"
hostOptions={hostOptions}
preserveWorkspaceBoardOpen={false}
setWorkspaceHostScope={vi.fn()}
visibleWorkspaceHostIds={[lastId]}
setVisibleWorkspaceHostIds={vi.fn()}
/>
)
expect(nodeCount()).toBeLessThan(NODE_BUDGET)
expect(screen.getAllByRole('menuitemcheckbox').length).toBeLessThanOrEqual(
HOST_LIST_ROW_LIMIT + 2
)
expect(screen.getByText(lastName)).toBeTruthy()
})
it('Notifications > Machines keeps muted hosts and finds the rest by search', () => {
const lastId = hostOptions[HOST_COUNT - 1]!.id
render(
<NotificationHostToggles
mutedNotificationSourceIds={[lastId]}
disabled={false}
onChange={vi.fn()}
/>
)
fireEvent.click(screen.getByText('Machines'))
expect(nodeCount()).toBeLessThan(NODE_BUDGET)
expect(screen.getByText(lastName)).toBeTruthy()
fireEvent.change(screen.getByRole('textbox', { name: 'Search hosts…' }), {
target: { value: 'node-00042' }
})
expect(screen.getByText('node-00042')).toBeTruthy()
})
it('Settings > General workspace directory "Apply to" mounts no host rows until opened', () => {
render(
<WorkspaceDirectorySetting settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />
)
expect(nodeCount()).toBeLessThan(NODE_BUDGET)
expect(screen.queryByText(lastName)).toBeNull()
})
})
@@ -0,0 +1,214 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SshTarget } from '../../../../shared/ssh-types'
import type { SidebarHostOption } from '../sidebar/sidebar-host-options'
import { HOST_LIST_ROW_LIMIT } from './bounded-host-list'
import { HostCombobox } from './HostCombobox'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { SshTargetSelect } from '../settings/SshTargetSelect'
import { SkillInstallMachineSelect } from '../skills/SkillInstallMachineSelect'
import { RemoteStep } from '../sidebar/AddRepoRemoteStep'
import { AddRepoHostSelector } from '../sidebar/AddRepoHostSelector'
import { buildRunTargetRows } from '../new-workspace/run-target-options'
import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options'
// Why 13,692: the host count from the generated ~/.ssh/config in the renderer crash this guards.
const HOST_COUNT = 13_692
// Generous ceiling for one bounded surface; drawing every host costs well over 100k nodes.
const NODE_BUDGET = 3_000
const names = Array.from({ length: HOST_COUNT }, (_, i) => `node-${String(i).padStart(5, '0')}`)
const lastName = names[HOST_COUNT - 1]!
function nodeCount(): number {
return document.body.getElementsByTagName('*').length
}
function sshTarget(name: string, index: number): SshTarget {
return { id: `ssh-${index}`, label: name, host: name, port: 22, username: 'deploy' }
}
afterEach(() => {
cleanup()
})
describe('host lists with a 13,692-host ssh config', () => {
it('HostCombobox mounts nothing while closed and a bounded, searchable list when open', async () => {
render(
<HostCombobox
value="ssh-0"
onChange={vi.fn()}
ariaLabel="Host"
options={names.map((name, i) => ({ id: `ssh-${i}`, label: name }))}
/>
)
expect(nodeCount()).toBeLessThan(50)
fireEvent.click(screen.getByRole('combobox', { name: 'Host' }))
const search = await screen.findByRole('combobox', { name: 'Search hosts…' })
// The current choice is pinned on top of a full page of others.
expect(screen.getAllByRole('option')).toHaveLength(HOST_LIST_ROW_LIMIT + 1)
expect(screen.getByText(/more hosts\. Search by name/)).toBeTruthy()
expect(nodeCount()).toBeLessThan(NODE_BUDGET)
fireEvent.change(search, { target: { value: lastName } })
expect(screen.getAllByRole('option')[0]?.textContent).toContain(lastName)
})
it('HostCombobox treats re-picking the current host as no change', async () => {
const onChange = vi.fn()
render(
<HostCombobox
value="ssh-0"
onChange={onChange}
ariaLabel="Host"
options={names.slice(0, 3).map((name, i) => ({ id: `ssh-${i}`, label: name }))}
/>
)
fireEvent.click(screen.getByRole('combobox', { name: 'Host' }))
fireEvent.click(await screen.findByRole('option', { name: names[0] }))
expect(onChange).not.toHaveBeenCalled()
})
it('Settings > Servers SSH host picker', async () => {
const onChange = vi.fn()
render(
<>
<label htmlFor="ssh-host">SSH host</label>
<SshTargetSelect
id="ssh-host"
targets={names.map(sshTarget)}
value=""
onChange={onChange}
placeholder="Choose an SSH host"
/>
</>
)
expect(nodeCount()).toBeLessThan(50)
fireEvent.click(screen.getByRole('combobox', { name: 'SSH host' }))
const search = await screen.findByRole('combobox', { name: 'Search hosts…' })
expect(nodeCount()).toBeLessThan(NODE_BUDGET)
fireEvent.change(search, { target: { value: lastName } })
fireEvent.click(screen.getByRole('option', { name: lastName }))
expect(onChange).toHaveBeenCalledWith(`ssh-${HOST_COUNT - 1}`)
})
it('skill install machine picker', async () => {
render(
<SkillInstallMachineSelect
value="local"
onChange={vi.fn()}
localLabel="This computer"
sshLabel="SSH"
disconnectedLabel="disconnected"
environments={[]}
sshTargets={names.map((name, i) => ({ id: `ssh-${i}`, label: name, connected: false }))}
/>
)
expect(nodeCount()).toBeLessThan(50)
fireEvent.click(screen.getByRole('combobox'))
await screen.findByRole('combobox', { name: 'Search hosts…' })
expect(nodeCount()).toBeLessThan(NODE_BUDGET)
})
it('Add Project remote step lists a server’s SSH hosts bounded, with the selection kept', () => {
render(
<Dialog open>
<DialogContent>
<RemoteStep
sshTargets={names.map(sshTarget)}
selectedTargetId={`ssh-${HOST_COUNT - 1}`}
remotePath=""
remoteError={null}
isAddingRemote={false}
onSelectTarget={vi.fn()}
onRemotePathChange={vi.fn()}
onAdd={vi.fn()}
onConnectTarget={vi.fn(async () => undefined)}
server={{ environmentId: 'env-1', label: 'build-box', onTargetsChanged: vi.fn() }}
/>
</DialogContent>
</Dialog>
)
expect(nodeCount()).toBeLessThan(NODE_BUDGET)
expect(screen.getAllByText(lastName).length).toBeGreaterThan(0)
fireEvent.change(screen.getByRole('textbox', { name: 'Search hosts…' }), {
target: { value: 'node-00042' }
})
expect(screen.getByText('node-00042')).toBeTruthy()
})
it('Add Project host selector', () => {
const hosts: SidebarHostOption[] = names.map((name, i) => ({
id: `ssh:ssh-${i}`,
label: name,
detail: '',
kind: 'ssh',
health: 'disconnected',
presence: 'configured'
}))
render(
<AddRepoHostSelector
hosts={hosts}
selectedHostId={hosts[0]!.id}
open
onOpenChange={vi.fn()}
onSelectHost={vi.fn()}
/>
)
expect(nodeCount()).toBeLessThan(NODE_BUDGET)
const list = screen.getByRole('listbox')
expect(within(list).getAllByRole('option').length).toBeLessThanOrEqual(HOST_LIST_ROW_LIMIT + 1)
})
it('Add Project host search hides setup actions so Enter picks the typed host', () => {
const hosts: SidebarHostOption[] = names.map((name, i) => ({
id: `runtime:server-${i}`,
label: name,
detail: '',
kind: 'runtime',
health: 'available',
presence: 'configured'
}))
const onSelectHost = vi.fn()
render(
<AddRepoHostSelector
hosts={hosts}
selectedHostId={hosts[0]!.id}
open
onOpenChange={vi.fn()}
onSelectHost={onSelectHost}
onAddSshHost={vi.fn()}
/>
)
expect(screen.getByText('Add remote host')).toBeTruthy()
const search = screen.getByRole('combobox', { name: 'Search hosts…' })
fireEvent.change(search, { target: { value: lastName } })
expect(screen.queryByText('Add remote host')).toBeNull()
fireEvent.keyDown(search, { key: 'Enter' })
expect(onSelectHost).toHaveBeenCalledWith(`runtime:server-${HOST_COUNT - 1}`)
})
it('composer run-target rows bound hosts that still need setup', () => {
const hostOptions = names.map((name, i): ProjectHostSetupOption => ({
kind: 'needs-setup',
id: `setup-${i}`,
projectId: 'project-1',
hostId: `ssh:ssh-${i}`,
label: name,
detail: 'Connect to set up',
isAvailable: false,
attention: false,
canSetLocation: false
}))
const all = buildRunTargetRows({ hostOptions, recipes: [], query: '', hasAddHost: false })
expect(all.rows.length).toBe(HOST_LIST_ROW_LIMIT)
expect(all.hiddenHostCount).toBe(HOST_COUNT - HOST_LIST_ROW_LIMIT)
const exact = buildRunTargetRows({
hostOptions,
recipes: [],
query: lastName,
hasAddHost: false
})
expect(exact.rows[0]?.kind === 'needs-setup' && exact.rows[0].option.label).toBe(lastName)
})
})
@@ -0,0 +1,23 @@
import { translate } from '@/i18n/i18n'
export function searchHostsLabel(): string {
return translate('auto.components.hostPicker.searchHosts', 'Search hosts…')
}
export function noHostsMatchLabel(): string {
return translate('auto.components.hostPicker.noHostsMatch', 'No hosts match.')
}
export function moreHostsLabel(count: number): string {
return count === 1
? translate(
'auto.components.hostPicker.moreHosts_one',
'{{count}} more host. Search by name to find it.',
{ count }
)
: translate(
'auto.components.hostPicker.moreHosts_other',
'{{count}} more hosts. Search by name to find one.',
{ count }
)
}
@@ -0,0 +1,19 @@
import { useState } from 'react'
import { boundHostList, HOST_LIST_ROW_LIMIT, type BoundedHostList } from './bounded-host-list'
/**
* A host checklist's rows: pinned hosts plus up to the limit of the rest, narrowed by a search the
* list shows only once there are more hosts than fit.
*/
export function useBoundedHostRows<T extends { id: string; label: string }>(
hosts: readonly T[],
pinnedIds?: ReadonlySet<string>
): BoundedHostList<T> & {
query: string
setQuery: (query: string) => void
searchable: boolean
} {
const [query, setQuery] = useState('')
const searchable = hosts.length > HOST_LIST_ROW_LIMIT || query !== ''
return { ...boundHostList(hosts, { query, pinnedIds }), query, setQuery, searchable }
}
@@ -21,6 +21,7 @@ import {
import { AddHostSubmenuRow } from './RunTargetSubmenus'
import RunTargetRowList from './RunTargetRowList'
import RunTargetField from './RunTargetField'
import { HostListOverflowNote } from '@/components/host-picker/HostListOverflowNote'
type RunTargetComboboxProps = {
hostOptions: readonly ProjectHostSetupOption[]
@@ -74,7 +75,7 @@ export default function RunTargetCombobox({
const { query, setQuery, open, setOpen, armedKey, arm, moveArm, inputRef, listId, setListNode } =
combobox
const { rows, matchedRecipes } = useMemo(
const { rows, matchedRecipes, hiddenHostCount } = useMemo(
() => buildRunTargetRows({ hostOptions, recipes, query, hasAddHost }),
[hasAddHost, hostOptions, query, recipes]
)
@@ -308,6 +309,8 @@ export default function RunTargetCombobox({
onSetLocation={setLocation}
/>
</div>
{/* Why outside the listbox: the note is not an option. */}
<HostListOverflowNote hiddenCount={hiddenHostCount} />
{hasAddHost ? (
<AddHostSubmenuRow
open={submenu === 'add-host'}
@@ -6,6 +6,7 @@ import {
type ReadyProjectHostSetupOption
} from '@/lib/project-host-setup-options'
import { translate } from '@/i18n/i18n'
import { boundHostList } from '@/components/host-picker/bounded-host-list'
export type EphemeralVmRecipeOption = NonNullable<OrcaHooks['environmentRecipes']>[number]
@@ -54,11 +55,15 @@ function matches(haystack: string, query: string): boolean {
return haystack.toLowerCase().includes(query)
}
/** Ready hosts first, then hosts that still need a connection or a project location. */
/**
* Ready hosts first, then hosts that still need a connection or a project location. Ready hosts
* already hold the project, so they are few; the rest can be every configured SSH host, so they
* are bounded and ranked by name.
*/
function buildHostRows(
hostOptions: readonly ProjectHostSetupOption[],
query: string
): RunTargetRowModel[] {
): { rows: RunTargetRowModel[]; hiddenHostCount: number } {
const hostMatches = (option: ProjectHostSetupOption): boolean =>
query === '' ||
matches(option.label, query) ||
@@ -69,22 +74,34 @@ function buildHostRows(
(option): option is ReadyProjectHostSetupOption =>
option.kind === 'ready' && hostMatches(option)
)
const needsSetup = hostOptions.filter(
(option): option is NeedsSetupProjectHostOption =>
option.kind === 'needs-setup' && hostMatches(option)
const needsSetup = boundHostList(
hostOptions
.filter(
(option): option is NeedsSetupProjectHostOption =>
option.kind === 'needs-setup' && hostMatches(option)
)
.map((option) => ({
id: getProjectHostSetupSelectionKey(option),
label: option.label,
searchText: option.detail,
option
})),
// Why the query again: ranks an exact name first so typing it always reaches the host.
{ query }
)
return [
const rows: RunTargetRowModel[] = [
...ready.map((option) => ({
key: `host:${getProjectHostSetupSelectionKey(option)}`,
kind: 'ready' as const,
option
})),
...needsSetup.map((option) => ({
key: `needs:${getProjectHostSetupSelectionKey(option)}`,
...needsSetup.shown.map(({ id, option }) => ({
key: `needs:${id}`,
kind: 'needs-setup' as const,
option
}))
]
return { rows, hiddenHostCount: needsSetup.hiddenCount }
}
function matchRecipes(
@@ -127,12 +144,18 @@ export function buildRunTargetRows({
recipes: readonly EphemeralVmRecipeOption[]
query: string
hasAddHost: boolean
}): { rows: RunTargetRowModel[]; matchedRecipes: EphemeralVmRecipeOption[] } {
}): {
rows: RunTargetRowModel[]
matchedRecipes: EphemeralVmRecipeOption[]
/** Hosts the row limit left out; typing narrows them in. */
hiddenHostCount: number
} {
const trimmed = query.trim().toLowerCase()
const matchedRecipes = matchRecipes(recipes, trimmed)
const hostRows = buildHostRows(hostOptions, trimmed)
const rows: RunTargetRowModel[] = [
...buildHostRows(hostOptions, trimmed),
...hostRows.rows,
...buildSubmenuRows(matchedRecipes.length > 0, hasAddHost)
]
return { rows, matchedRecipes }
return { rows, matchedRecipes, hiddenHostCount: hostRows.hiddenHostCount }
}
@@ -6,6 +6,9 @@ import { NotificationSettingToggle } from './NotificationSettingToggle'
import { useNotificationSourceOptions } from './use-notification-source-options'
import { pickerExecutionHosts } from '../../../../shared/managed-orcad-execution-host'
import { translate } from '@/i18n/i18n'
import { useBoundedHostRows } from '../host-picker/use-bounded-host-rows'
import { HostListSearchField } from '../host-picker/HostListSearchField'
import { HostListOverflowNote } from '../host-picker/HostListOverflowNote'
type NotificationHostTogglesProps = {
mutedNotificationSourceIds: readonly NotificationSourceId[]
@@ -23,7 +26,10 @@ export function NotificationHostToggles({
const mutedSourceIds = new Set(mutedNotificationSourceIds)
const isMuted = (host: (typeof hostOptions)[number]): boolean =>
[host.id, ...(host.aliasHostIds ?? [])].some((id) => mutedSourceIds.has(id))
const mutedCount = hostOptions.filter(isMuted).length
const mutedHosts = hostOptions.filter(isMuted)
const mutedCount = mutedHosts.length
// Why muted ones pinned: an off switch must never be hidden behind the row cap.
const rows = useBoundedHostRows(hostOptions, new Set(mutedHosts.map((host) => host.id)))
// Keep an effective mute reachable after the last remote machine is removed.
if (hostOptions.length <= 1 && mutedCount === 0) {
return null
@@ -62,7 +68,10 @@ export function NotificationHostToggles({
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-4">
{hostOptions.map((host) => (
{rows.searchable ? (
<HostListSearchField value={rows.query} onChange={rows.setQuery} />
) : null}
{rows.shown.map((host) => (
<NotificationSettingToggle
key={host.id}
label={host.label}
@@ -72,6 +81,7 @@ export function NotificationHostToggles({
onToggle={() => onChange([host.id, ...(host.aliasHostIds ?? [])], !isMuted(host))}
/>
))}
<HostListOverflowNote hiddenCount={rows.hiddenCount} />
</div>
</CollapsibleContent>
</Collapsible>
@@ -9,7 +9,7 @@ import type {
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { HostCombobox } from '../host-picker/HostCombobox'
import type { SetupHostOption } from './repository-host-setup-options'
import {
HostSetupCloneStep,
@@ -217,28 +217,22 @@ export function RepositoryHostSetupActions({
<Label className="text-xs font-medium text-muted-foreground">
{translate('auto.components.settings.RepositoryPane.setupHostLabel', 'Host')}
</Label>
<Select
value={setupTargetHostId ?? undefined}
onValueChange={(value) => setSelectedSetupHostId(value as ExecutionHostId)}
>
<SelectTrigger className="h-9 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{setupHostOptions.map((option) => (
<SelectItem key={option.id} value={option.id} disabled={!option.isAvailable}>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{!option.isAvailable || !option.canUsePathActions ? (
<span className="block truncate text-[11px] text-muted-foreground">
{option.detail}
</span>
) : null}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<HostCombobox
value={setupTargetHostId ?? ''}
onChange={(value) => {
const option = setupHostOptions.find((candidate) => candidate.id === value)
if (option) {
setSelectedSetupHostId(option.id)
}
}}
options={setupHostOptions.map((option) => ({
id: option.id,
label: option.label,
disabled: !option.isAvailable,
detail: !option.isAvailable || !option.canUsePathActions ? option.detail : undefined
}))}
className="min-w-0"
/>
{(!canUseSetupTargetHost || !canUsePathActions) && setupTargetHostOption ? (
<p className="text-xs text-muted-foreground">{setupTargetHostOption.detail}</p>
) : null}
@@ -1,5 +1,5 @@
import type { SshTarget } from '../../../../shared/ssh-types'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { HostCombobox } from '../host-picker/HostCombobox'
type SshTargetSelectProps = {
id?: string
@@ -17,17 +17,12 @@ export function SshTargetSelect({
placeholder
}: SshTargetSelectProps): React.JSX.Element {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{targets.map((target) => (
<SelectItem key={target.id} value={target.id}>
{target.label}
</SelectItem>
))}
</SelectContent>
</Select>
<HostCombobox
id={id}
value={value}
onChange={onChange}
options={targets}
placeholder={placeholder}
/>
)
}
@@ -11,7 +11,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { HostCombobox } from '../host-picker/HostCombobox'
import { SearchableSetting } from './SearchableSetting'
import { useSidebarHostScopeOptions } from '../sidebar/use-sidebar-host-scope-options'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
@@ -171,21 +171,18 @@ export function WorkspaceDirectorySetting({
'Apply to'
)}
</span>
<Select
<HostCombobox
value={activeScope}
onValueChange={(next) => setScope(next as HostSettingScope)}
>
<SelectTrigger size="sm" className="h-7 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{choices.map((choice) => (
<SelectItem key={choice.scope} value={choice.scope} className="text-xs">
{choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(next) => {
const choice = choices.find((candidate) => candidate.scope === next)
if (choice) {
setScope(choice.scope)
}
}}
options={choices.map((choice) => ({ id: choice.scope, label: choice.label }))}
size="xs"
className="w-44"
/>
</div>
)}
</div>
@@ -1,7 +1,10 @@
import { useState } from 'react'
import { Check, ChevronRight, ChevronsUpDown, Loader2, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Command, CommandItem, CommandList } from '@/components/ui/command'
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { useBoundedHostRows } from '../host-picker/use-bounded-host-rows'
import { HostListOverflowNote } from '../host-picker/HostListOverflowNote'
import { searchHostsLabel } from '../host-picker/host-picker-copy'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import type { SidebarHostOption } from './sidebar-host-options'
@@ -50,6 +53,7 @@ export function AddRepoHostSelector({
onOpenWslHostsSetting
}: AddRepoHostSelectorProps): React.JSX.Element | null {
const [addHostOpen, setAddHostOpen] = useState(false)
const rows = useBoundedHostRows(hosts, new Set(selectedHostId ? [selectedHostId] : []))
const showHostSetupActions = Boolean(onAddSshHost || onAddRemoteServer || onAddWslHost)
if (!shouldShowHostScopeControls(hosts) && !showHostSetupActions) {
return null
@@ -89,9 +93,18 @@ export function AddRepoHostSelector({
align="start"
className="w-[min(340px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0"
>
<Command>
<Command shouldFilter={false} label={searchHostsLabel()}>
{rows.searchable ? (
<CommandInput
value={rows.query}
onValueChange={rows.setQuery}
placeholder={searchHostsLabel()}
aria-label={searchHostsLabel()}
/>
) : null}
<CommandList>
{showHostSetupActions ? (
{/* Why hidden while searching: Enter must pick the matching host, not open setup. */}
{showHostSetupActions && rows.query === '' ? (
<Popover open={addHostOpen} onOpenChange={setAddHostOpen}>
<PopoverTrigger asChild>
<CommandItem
@@ -200,7 +213,7 @@ export function AddRepoHostSelector({
</PopoverContent>
</Popover>
) : null}
{hosts.map((host) => {
{rows.shown.map((host) => {
const selected = host.id === selectedHostId
const disabled = !canSelectAddRepoHost(host)
const canConnectHost = canConnectAddRepoHost(host)
@@ -288,6 +301,7 @@ export function AddRepoHostSelector({
)
})}
</CommandList>
<HostListOverflowNote hiddenCount={rows.hiddenCount} />
</Command>
</PopoverContent>
</Popover>
@@ -8,6 +8,9 @@ import { RemoteFileBrowser } from './RemoteFileBrowser'
import { SshTargetRow } from './SshTargetRow'
import { AddServerSshHostDialog } from './AddServerSshHostDialog'
import { translate } from '@/i18n/i18n'
import { useBoundedHostRows } from '../host-picker/use-bounded-host-rows'
import { HostListSearchField } from '../host-picker/HostListSearchField'
import { HostListOverflowNote } from '../host-picker/HostListOverflowNote'
type RemoteStepProps = {
sshTargets: (SshTarget & { state?: SshConnectionState })[]
@@ -41,6 +44,10 @@ export function RemoteStep({
}: RemoteStepProps): React.JSX.Element {
const [browsing, setBrowsing] = useState(false)
const [addingServerHost, setAddingServerHost] = useState(false)
const targetRows = useBoundedHostRows(
sshTargets,
new Set(selectedTargetId ? [selectedTargetId] : [])
)
const selectedTarget = selectedTargetId
? sshTargets.find((target) => target.id === selectedTargetId)
: null
@@ -111,7 +118,10 @@ export function RemoteStep({
</p>
) : (
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1 scrollbar-sleek">
{sshTargets.map((target) => (
{targetRows.searchable ? (
<HostListSearchField value={targetRows.query} onChange={targetRows.setQuery} />
) : null}
{targetRows.shown.map((target) => (
<SshTargetRow
key={target.id}
target={target}
@@ -120,6 +130,7 @@ export function RemoteStep({
onConnect={onConnectTarget}
/>
))}
<HostListOverflowNote hiddenCount={targetRows.hiddenCount} />
</div>
)}
<Button
@@ -17,6 +17,9 @@ import {
toggleVisibleHostRow
} from '../../../../shared/workspace-view/workspace-host-visibility'
import { getSidebarHostHealthLabel, type SidebarHostOption } from './sidebar-host-options'
import { useBoundedHostRows } from '../host-picker/use-bounded-host-rows'
import { HostListSearchField } from '../host-picker/HostListSearchField'
import { HostListOverflowNote } from '../host-picker/HostListOverflowNote'
import { translate } from '@/i18n/i18n'
type SidebarHostScopeMenuSectionProps = {
@@ -76,6 +79,9 @@ export function SidebarHostScopeMenuSection({
).map((host) => host.id)
)
// Why checked rows pinned under a filter: a host the filter shows must stay uncheckable.
const rows = useBoundedHostRows(hostRows, allVisible ? undefined : checkedRowIds)
const toggleAllHosts = (): void => {
const next = toggleAllVisibleHosts(hostOptions, visibleWorkspaceHostIds)
if (next === null) {
@@ -128,23 +134,30 @@ export function SidebarHostScopeMenuSection({
</span>
</span>
</DropdownMenuCheckboxItem>
{hostRows.map((host) => (
<DropdownMenuCheckboxItem
key={host.id}
checked={!allVisible && checkedRowIds.has(host.id)}
disabled={!allVisible && checkedRowIds.has(host.id) && checkedRowIds.size <= 1}
onCheckedChange={() => toggleHost(host)}
onSelect={(e) => e.preventDefault()}
className="min-h-11 items-start py-1.5"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate">{host.label}</span>
<span className="text-[11px] font-normal text-muted-foreground">
{getHostMetadata(host)}
{rows.searchable ? (
<HostListSearchField value={rows.query} onChange={rows.setQuery} inMenu />
) : null}
{/* Why a max height: up to 50 host rows plus pinned ones must scroll, not run off screen. */}
<div className="max-h-80 overflow-y-auto scrollbar-sleek">
{rows.shown.map((host) => (
<DropdownMenuCheckboxItem
key={host.id}
checked={!allVisible && checkedRowIds.has(host.id)}
disabled={!allVisible && checkedRowIds.has(host.id) && checkedRowIds.size <= 1}
onCheckedChange={() => toggleHost(host)}
onSelect={(e) => e.preventDefault()}
className="min-h-11 items-start py-1.5"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate">{host.label}</span>
<span className="text-[11px] font-normal text-muted-foreground">
{getHostMetadata(host)}
</span>
</span>
</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuCheckboxItem>
))}
</div>
<HostListOverflowNote hiddenCount={rows.hiddenCount} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)
@@ -1,10 +1,4 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { HostCombobox } from '../host-picker/HostCombobox'
export function SkillInstallMachineSelect(props: {
value: string
@@ -16,23 +10,23 @@ export function SkillInstallMachineSelect(props: {
sshTargets: readonly { id: string; label: string; connected: boolean }[]
}): React.JSX.Element {
return (
<Select value={props.value} onValueChange={props.onChange}>
<SelectTrigger className="w-full sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">{props.localLabel}</SelectItem>
{props.environments.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
{props.sshTargets.map((target) => (
<SelectItem key={target.id} value={target.id} disabled={!target.connected}>
{target.label} {target.connected ? props.sshLabel : props.disconnectedLabel}
</SelectItem>
))}
</SelectContent>
</Select>
<HostCombobox
value={props.value}
onChange={props.onChange}
className="sm:w-64"
options={[
{ id: 'local', label: props.localLabel },
...props.environments.map((environment) => ({
id: environment.id,
label: environment.name
})),
...props.sshTargets.map((target) => ({
id: target.id,
label: target.label,
detail: target.connected ? props.sshLabel : props.disconnectedLabel,
disabled: !target.connected
}))
]}
/>
)
}
@@ -13,6 +13,7 @@ import { SkillInstallAgentPicker } from './SkillInstallAgentPicker'
import { SkillInstallWorkspaceCombobox } from './SkillInstallWorkspaceCombobox'
import type { SkillInstallWorkspaceChoice } from './skill-install-workspace-choices'
import { translate } from '@/i18n/i18n'
import { HostCombobox, type HostComboboxOption } from '../host-picker/HostCombobox'
export function SkillInstallTargetFields(props: {
environmentId: string
@@ -68,64 +69,17 @@ export function SkillInstallTargetFields(props: {
<Label htmlFor={`${fieldId}-machine`}>
{translate('auto.components.skills.SkillInstallTargetFields.b8a0b706ad', 'Machine')}
</Label>
<Select
<HostCombobox
id={`${fieldId}-machine`}
value={props.environmentId}
onValueChange={(value) => {
onChange={(value) => {
props.onEnvironmentChange(value)
props.onWorkspaceChange('')
props.onExecutionTargetChange(null)
}}
>
<SelectTrigger id={`${fieldId}-machine`} className="w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">
{translate(
'auto.components.skills.SkillInstallTargetFields.8562dd1e6e',
'This computer'
)}
</SelectItem>
{props.runtimeEnvironments.map((environment) => {
const status = props.runtimeStatus.get(environment.id)?.status
const unsupported =
status !== null &&
status !== undefined &&
status.capabilities?.includes(
props.requiredCapability ?? SKILL_INSTALL_CAPABILITY
) !== true
return (
<SelectItem key={environment.id} value={environment.id} disabled={unsupported}>
{environment.name}{' '}
{unsupported
? translate(
'auto.components.skills.SkillInstallTargetFields.0785d0a503',
'— update required'
)
: ''}
</SelectItem>
)
})}
{props.sshConnections.map((connection) => (
<SelectItem
key={`ssh:${connection.id}`}
value={`ssh:${connection.id}`}
disabled={!connection.connected}
>
{connection.label}{' '}
{!connection.connected
? translate(
'auto.components.skills.SkillInstallTargetFields.71eefd7660',
'— disconnected'
)
: translate(
'auto.components.skills.SkillInstallTargetFields.85d85880df',
'· SSH'
)}
</SelectItem>
))}
</SelectContent>
</Select>
options={skillInstallMachineOptions(props)}
className="min-w-0"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${fieldId}-destination`}>
@@ -227,3 +181,42 @@ export function SkillInstallTargetFields(props: {
</>
)
}
function skillInstallMachineOptions(props: {
runtimeEnvironments: readonly { id: string; name: string }[]
runtimeStatus: Map<string, { status: { capabilities?: string[] } | null }>
sshConnections: readonly { id: string; label: string; connected: boolean }[]
requiredCapability?: string
}): HostComboboxOption[] {
const local: HostComboboxOption = {
id: 'local',
label: translate('auto.components.skills.SkillInstallTargetFields.8562dd1e6e', 'This computer')
}
const environments = props.runtimeEnvironments.map((environment): HostComboboxOption => {
const status = props.runtimeStatus.get(environment.id)?.status
const unsupported =
status !== null &&
status !== undefined &&
status.capabilities?.includes(props.requiredCapability ?? SKILL_INSTALL_CAPABILITY) !== true
return {
id: environment.id,
label: environment.name,
disabled: unsupported,
detail: unsupported
? translate(
'auto.components.skills.SkillInstallTargetFields.0785d0a503',
'— update required'
)
: undefined
}
})
const ssh = props.sshConnections.map((connection): HostComboboxOption => ({
id: `ssh:${connection.id}`,
label: connection.label,
disabled: !connection.connected,
detail: connection.connected
? translate('auto.components.skills.SkillInstallTargetFields.85d85880df', '· SSH')
: translate('auto.components.skills.SkillInstallTargetFields.71eefd7660', '— disconnected')
}))
return [local, ...environments, ...ssh]
}
@@ -67,7 +67,7 @@ export function SshHostFindPopover({
}
}}
>
<Command shouldFilter={false}>
<Command shouldFilter={false} label={searchSshHostsLabel()}>
<CommandInput
value={query}
onValueChange={setQuery}
@@ -1,8 +1,9 @@
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import { boundHostList, HOST_LIST_ROW_LIMIT } from '../host-picker/bounded-host-list'
// Why: a generated ~/.ssh/config can import >10k hosts; a row each made one open cost GBs of DOM.
export const SSH_STATUS_MENU_IDLE_ROW_LIMIT = 25
export const SSH_HOST_FIND_RESULT_LIMIT = 50
export const SSH_HOST_FIND_RESULT_LIMIT = HOST_LIST_ROW_LIMIT
export type SshStatusMenuTarget = { id: string; label: string; status: SshConnectionStatus }
@@ -40,32 +41,15 @@ export function splitSshStatusMenuRows(
}
}
/**
* Not-connected hosts matching `query`, exact names first, then prefixes, then substrings, so any
* host is reachable by typing its full name however many others share its prefix.
*/
/** Not-connected hosts matching `query`, through the shared host-list bound and ranking. */
export function findSshHostMatches(
targets: readonly SshStatusMenuTarget[],
query: string,
limit = SSH_HOST_FIND_RESULT_LIMIT
): { matches: SshStatusMenuTarget[]; moreCount: number } {
const needle = query.trim().toLowerCase()
const exact: SshStatusMenuTarget[] = []
const prefix: SshStatusMenuTarget[] = []
const contains: SshStatusMenuTarget[] = []
for (const target of targets) {
if (target.status === 'connected') {
continue
}
const label = target.label.toLowerCase()
if (label === needle) {
exact.push(target)
} else if (label.startsWith(needle)) {
prefix.push(target)
} else if (label.includes(needle)) {
contains.push(target)
}
}
const all = [...exact, ...prefix, ...contains]
return { matches: all.slice(0, limit), moreCount: Math.max(0, all.length - limit) }
const { shown, hiddenCount } = boundHostList(
targets.filter((target) => target.status !== 'connected'),
{ query, limit }
)
return { matches: shown, moreCount: hiddenCount }
}
+4
View File
@@ -602,6 +602,10 @@
}
}
},
"hostPicker": {
"moreHosts_one": "{{count}} more host. Search by name to find it.",
"moreHosts_other": "{{count}} more hosts. Search by name to find one."
},
"linear-issue-attribute-filter-dropdowns": {
"optionsFromTeam": "Options from {{team}}"
},
+6
View File
@@ -1473,6 +1473,12 @@
}
},
"components": {
"hostPicker": {
"searchHosts": "Search hosts…",
"noHostsMatch": "No hosts match.",
"moreHosts_one": "{{count}} more host. Search by name to find it.",
"moreHosts_other": "{{count}} more hosts. Search by name to find one."
},
"BrowserCookieImportDisclosure": {
"title": "Google logins aren't imported",
"description": "Sign in to Google directly in Orca."
+6
View File
@@ -1274,6 +1274,12 @@
}
},
"components": {
"hostPicker": {
"searchHosts": "Buscar hosts…",
"noHostsMatch": "Ningún host coincide.",
"moreHosts_one": "{{count}} host más. Búscalo por nombre.",
"moreHosts_other": "{{count}} hosts más. Busca por nombre para encontrar uno."
},
"CodexRestartChip": {
"c72a5fb234": "Reiniciar",
"9263e75f49": "Codex está usando la cuenta anterior.",
+6
View File
@@ -1379,6 +1379,12 @@
}
},
"components": {
"hostPicker": {
"searchHosts": "Rechercher des hôtes…",
"noHostsMatch": "Aucun hôte ne correspond.",
"moreHosts_one": "{{count}} autre hôte. Recherchez-le par son nom.",
"moreHosts_other": "{{count}} autres hôtes. Recherchez par nom pour en trouver un."
},
"BrowserCookieImportDisclosure": {
"title": "Connexions Google non importées",
"description": "Connectez-vous à Google directement dans Orca."
+6
View File
@@ -1275,6 +1275,12 @@
}
},
"components": {
"hostPicker": {
"searchHosts": "ホストを検索…",
"noHostsMatch": "一致するホストはありません。",
"moreHosts_one": "ほかに {{count}} 台のホストがあります。名前で検索してください。",
"moreHosts_other": "ほかに {{count}} 台のホストがあります。名前で検索してください。"
},
"CodexRestartChip": {
"c72a5fb234": "再起動",
"9263e75f49": "Codex は前のアカウントを使用しています",
+6
View File
@@ -1275,6 +1275,12 @@
}
},
"components": {
"hostPicker": {
"searchHosts": "호스트 검색…",
"noHostsMatch": "일치하는 호스트가 없습니다.",
"moreHosts_one": "호스트 {{count}}개 더 있음. 이름으로 검색하세요.",
"moreHosts_other": "호스트 {{count}}개 더 있음. 이름으로 검색하세요."
},
"CodexRestartChip": {
"c72a5fb234": "다시 시작",
"9263e75f49": "Codex는 이전 계정을 사용하고 있습니다",
+6
View File
@@ -1275,6 +1275,12 @@
}
},
"components": {
"hostPicker": {
"searchHosts": "搜索主机…",
"noHostsMatch": "没有匹配的主机。",
"moreHosts_one": "还有 {{count}} 个主机,请按名称搜索。",
"moreHosts_other": "还有 {{count}} 个主机,请按名称搜索。"
},
"CodexRestartChip": {
"c72a5fb234": "重新启动",
"9263e75f49": "Codex 正在使用之前的账户",