mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
@@ -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
@@ -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}}"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1275,6 +1275,12 @@
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"hostPicker": {
|
||||
"searchHosts": "ホストを検索…",
|
||||
"noHostsMatch": "一致するホストはありません。",
|
||||
"moreHosts_one": "ほかに {{count}} 台のホストがあります。名前で検索してください。",
|
||||
"moreHosts_other": "ほかに {{count}} 台のホストがあります。名前で検索してください。"
|
||||
},
|
||||
"CodexRestartChip": {
|
||||
"c72a5fb234": "再起動",
|
||||
"9263e75f49": "Codex は前のアカウントを使用しています",
|
||||
|
||||
@@ -1275,6 +1275,12 @@
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"hostPicker": {
|
||||
"searchHosts": "호스트 검색…",
|
||||
"noHostsMatch": "일치하는 호스트가 없습니다.",
|
||||
"moreHosts_one": "호스트 {{count}}개 더 있음. 이름으로 검색하세요.",
|
||||
"moreHosts_other": "호스트 {{count}}개 더 있음. 이름으로 검색하세요."
|
||||
},
|
||||
"CodexRestartChip": {
|
||||
"c72a5fb234": "다시 시작",
|
||||
"9263e75f49": "Codex는 이전 계정을 사용하고 있습니다",
|
||||
|
||||
@@ -1275,6 +1275,12 @@
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"hostPicker": {
|
||||
"searchHosts": "搜索主机…",
|
||||
"noHostsMatch": "没有匹配的主机。",
|
||||
"moreHosts_one": "还有 {{count}} 个主机,请按名称搜索。",
|
||||
"moreHosts_other": "还有 {{count}} 个主机,请按名称搜索。"
|
||||
},
|
||||
"CodexRestartChip": {
|
||||
"c72a5fb234": "重新启动",
|
||||
"9263e75f49": "Codex 正在使用之前的账户",
|
||||
|
||||
Reference in New Issue
Block a user