diff --git a/config/host-list-render-baseline.txt b/config/host-list-render-baseline.txt new file mode 100644 index 00000000000..6bed7509997 --- /dev/null +++ b/config/host-list-render-baseline.txt @@ -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 diff --git a/config/scripts/check-host-list-render-ratchet.mjs b/config/scripts/check-host-list-render-ratchet.mjs new file mode 100644 index 00000000000..54a1c8cc1f5 --- /dev/null +++ b/config/scripts/check-host-list-render-ratchet.mjs @@ -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) => )`). 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' +] + +// `.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*)? 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])) +} diff --git a/config/scripts/check-host-list-render-ratchet.test.mjs b/config/scripts/check-host-list-render-ratchet.test.mjs new file mode 100644 index 00000000000..91dc366e6b0 --- /dev/null +++ b/config/scripts/check-host-list-render-ratchet.test.mjs @@ -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 \n))}' + ) + ).toBe(1) + expect( + countHostCollectionJsxMaps('{props.sshConnections.map((c) => )}') + ).toBe(1) + expect(countHostCollectionJsxMaps('{targets.map(t =>
  • )}')).toBe(1) + }) + + it('ignores bounded rows, data maps and comments', () => { + expect(countHostCollectionJsxMaps('{rows.shown.map((host) => )}')).toBe(0) + expect(countHostCollectionJsxMaps('{targetRows.shown.map((t) => )}')).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) => )')).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) + }) +}) diff --git a/package.json b/package.json index 86dfe92d3a9..32b4ee27433 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/components/automations/AutomationDestinationField.tsx b/src/renderer/src/components/automations/AutomationDestinationField.tsx index e3ef789b670..8dd426203b7 100644 --- a/src/renderer/src/components/automations/AutomationDestinationField.tsx +++ b/src/renderer/src/components/automations/AutomationDestinationField.tsx @@ -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 ( - + + group.entries.map((entry) => ({ + id: entry.stableKey, + label: entry.label, + group: group.authorityLabel, + disabled: !automationCreateHostEligible(entry), + content: ( + + + + + ) + })) + )} + /> {selected && control.projects.length === 0 ? ( // Otherwise Create is disabled for an empty project list with nothing said.

    diff --git a/src/renderer/src/components/automations/AutomationListFilterMenu.tsx b/src/renderer/src/components/automations/AutomationListFilterMenu.tsx index e9b2c2cab02..201f19dd51f 100644 --- a/src/renderer/src/components/automations/AutomationListFilterMenu.tsx +++ b/src/renderer/src/components/automations/AutomationListFilterMenu.tsx @@ -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 ? ( + + ) : null} + {rows.shown.map((row) => ( + onToggle(row.id)} + onSelect={(event) => event.preventDefault()} + > + {row.label} + + ))} + + + ) +} + export function AutomationListFilterMenu({ filter, onChange, @@ -211,16 +249,11 @@ export function AutomationListFilterMenu({ > {translate('auto.components.automations.hostPicker.allHosts', 'All hosts')} - {hostEntries.map((entry) => ( - toggleHost(entry.stableKey)} - onSelect={(event) => event.preventDefault()} - > - {entry.label} - - ))} + diff --git a/src/renderer/src/components/host-picker/HostCombobox.tsx b/src/renderer/src/components/host-picker/HostCombobox.tsx new file mode 100644 index 00000000000..4c52f36ff52 --- /dev/null +++ b/src/renderer/src/components/host-picker/HostCombobox.tsx @@ -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() + 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 ( + { + setOpen(next) + if (next) { + setQuery('') + } + }} + > + + + + + + + + {noHostsMatchLabel()} + {groupOptions(shown).map((group) => ( + + {group.options.map((option) => ( + { + setOpen(false) + // Why: like a Select, re-picking the current host is not a change. + if (option.id !== value) { + onChange(option.id) + } + }} + > + {option.content ?? ( + <> + {option.label} + {option.detail ? ( + {option.detail} + ) : null} + + )} + {option.id === value ? : null} + + ))} + + ))} + + + + + + ) +} diff --git a/src/renderer/src/components/host-picker/HostListOverflowNote.tsx b/src/renderer/src/components/host-picker/HostListOverflowNote.tsx new file mode 100644 index 00000000000..bd0f544614b --- /dev/null +++ b/src/renderer/src/components/host-picker/HostListOverflowNote.tsx @@ -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 ( +

    + {moreHostsLabel(hiddenCount)} +
    + ) +} diff --git a/src/renderer/src/components/host-picker/HostListSearchField.tsx b/src/renderer/src/components/host-picker/HostListSearchField.tsx new file mode 100644 index 00000000000..39726ef10c2 --- /dev/null +++ b/src/renderer/src/components/host-picker/HostListSearchField.tsx @@ -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(MENU_ITEM_SELECTOR) + if (!menu || !item) { + return + } + const items = Array.from(menu.querySelectorAll(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 + onKeyDown?: React.KeyboardEventHandler +}): React.JSX.Element { + return ( + 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(null) + if (!inMenu) { + return ( +
    + +
    + ) + } + return ( + event.preventDefault()} + onFocus={(event) => { + if (event.target === event.currentTarget) { + inputRef.current?.focus() + } + }} + > + { + 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() + } + }} + /> + + ) +} diff --git a/src/renderer/src/components/host-picker/bounded-host-list.test.tsx b/src/renderer/src/components/host-picker/bounded-host-list.test.tsx new file mode 100644 index 00000000000..554531c08f3 --- /dev/null +++ b/src/renderer/src/components/host-picker/bounded-host-list.test.tsx @@ -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( + + + All hosts + + node-1 + + + ) + 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') + }) +}) diff --git a/src/renderer/src/components/host-picker/bounded-host-list.ts b/src/renderer/src/components/host-picker/bounded-host-list.ts new file mode 100644 index 00000000000..9a9dba92827 --- /dev/null +++ b/src/renderer/src/components/host-picker/bounded-host-list.ts @@ -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 = { + 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( + hosts: readonly T[], + options: { query?: string; pinnedIds?: ReadonlySet; limit?: number } = {} +): BoundedHostList { + 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) + } +} diff --git a/src/renderer/src/components/host-picker/host-list-menus.large-config.test.tsx b/src/renderer/src/components/host-picker/host-list-menus.large-config.test.tsx new file mode 100644 index 00000000000..3ddd87236a2 --- /dev/null +++ b/src/renderer/src/components/host-picker/host-list-menus.large-config.test.tsx @@ -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 }) =>
    {children}
    + return { + DropdownMenuSub: Pass, + DropdownMenuSubTrigger: Pass, + DropdownMenuSubContent: Pass, + DropdownMenuItem: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
    + ), + DropdownMenuCheckboxItem: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
    + ) + } +}) +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( + + ) + 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( + + ) + 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( + + ) + expect(nodeCount()).toBeLessThan(NODE_BUDGET) + expect(screen.queryByText(lastName)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/host-picker/host-list-surfaces.large-config.test.tsx b/src/renderer/src/components/host-picker/host-list-surfaces.large-config.test.tsx new file mode 100644 index 00000000000..0f7e1d68057 --- /dev/null +++ b/src/renderer/src/components/host-picker/host-list-surfaces.large-config.test.tsx @@ -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( + ({ 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( + ({ 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( + <> + + + + ) + 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( + ({ 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( + + + undefined)} + server={{ environmentId: 'env-1', label: 'build-box', onTargetsChanged: vi.fn() }} + /> + + + ) + 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( + + ) + 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( + + ) + 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) + }) +}) diff --git a/src/renderer/src/components/host-picker/host-picker-copy.ts b/src/renderer/src/components/host-picker/host-picker-copy.ts new file mode 100644 index 00000000000..dd0ca93d311 --- /dev/null +++ b/src/renderer/src/components/host-picker/host-picker-copy.ts @@ -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 } + ) +} diff --git a/src/renderer/src/components/host-picker/use-bounded-host-rows.ts b/src/renderer/src/components/host-picker/use-bounded-host-rows.ts new file mode 100644 index 00000000000..94a5dbca03e --- /dev/null +++ b/src/renderer/src/components/host-picker/use-bounded-host-rows.ts @@ -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( + hosts: readonly T[], + pinnedIds?: ReadonlySet +): BoundedHostList & { + 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 } +} diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx index fcab5b2c5a5..8d8306abfe4 100644 --- a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx @@ -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} /> + {/* Why outside the listbox: the note is not an option. */} + {hasAddHost ? ( [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 } } diff --git a/src/renderer/src/components/settings/NotificationHostToggles.tsx b/src/renderer/src/components/settings/NotificationHostToggles.tsx index f63129375f7..92c3cc58084 100644 --- a/src/renderer/src/components/settings/NotificationHostToggles.tsx +++ b/src/renderer/src/components/settings/NotificationHostToggles.tsx @@ -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({
    - {hostOptions.map((host) => ( + {rows.searchable ? ( + + ) : null} + {rows.shown.map((host) => ( onChange([host.id, ...(host.aliasHostIds ?? [])], !isMuted(host))} /> ))} +
    diff --git a/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx index a3527747681..f035ad60bb3 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx @@ -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({ - + { + 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 ? (

    {setupTargetHostOption.detail}

    ) : null} diff --git a/src/renderer/src/components/settings/SshTargetSelect.tsx b/src/renderer/src/components/settings/SshTargetSelect.tsx index 91294c7b123..b4a96e8960b 100644 --- a/src/renderer/src/components/settings/SshTargetSelect.tsx +++ b/src/renderer/src/components/settings/SshTargetSelect.tsx @@ -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 ( - + ) } diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx index 6ca5e985170..fc4056df4f9 100644 --- a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -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' )} - + 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" + /> )} diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx index d766ac4ab7b..4b237ce425b 100644 --- a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx +++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx @@ -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" > - + + {rows.searchable ? ( + + ) : null} - {showHostSetupActions ? ( + {/* Why hidden while searching: Enter must pick the matching host, not open setup. */} + {showHostSetupActions && rows.query === '' ? ( ) : 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({ ) })} + diff --git a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx index 2304e77bfae..c086bdb0403 100644 --- a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx @@ -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({

    ) : (
    - {sshTargets.map((target) => ( + {targetRows.searchable ? ( + + ) : null} + {targetRows.shown.map((target) => ( ))} +
    )}