From 01562f8bccf7d9b37dd0111d05db3dc11d1403ff Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:51:27 -0700 Subject: [PATCH] fix(ssh): window the SSH Hosts list and search past the Remote Hosts cap, so a 13k-host ssh config no longer crashes the renderer (#31405) * fix(ssh): the Remote Hosts menu lists a capped set of idle SSH hosts * fix(ssh): the SSH Hosts settings list renders hosts a page at a time * fix(ssh): window the SSH Hosts settings list and keep project hosts in the capped menu Settings > SSH Hosts now windows its rows against the Settings scroller with the shared VirtualizedList instead of paging 100 at a time, so every host stays reachable by scrolling or the filter while only the rows near the viewport mount. The Remote Hosts menu cap rises to 25 idle hosts, fills it with hosts that own projects first, and the overflow row says to search the rest in Settings. * fix(ssh): search the Remote Hosts menu's capped idle hosts in place Web clients reach SSH hosts through this menu but have no SSH Hosts settings pane, so a link to Settings stranded every host past the cap. The menu now shows a search box whenever idle hosts are hidden; it narrows them by name, and the overflow line says how many are left. Also gives the two existing status-bar store mocks the repos field the menu now reads. * fix(ssh): typing in the Remote Hosts menu goes to its host search The search box is not a menu item, so arrow keys can't reach it; letters typed while the menu has focus now land in the search instead of Radix typeahead. * fix(ssh): the Remote Hosts search keeps focus and drives its results by keyboard The host rows are not menu items, so the menu's arrow keys never reach them. The search box is now a combobox over the connectable results: it keeps focus, arrow keys move the active result (announced through aria-activedescendant), and Enter connects it through the same path as the row's Connect button. * fix(ssh): Remote Hosts search only reaches hosts that match, and keeps the active one in view Every not-connected row now has to match the search, so Enter can't connect a failed host the user didn't type. Arrow keys scroll the active result into view. Also drops restyles on the search Input and fixes the singular overflow default. * refactor(ssh): find hidden Remote Hosts in a searchable picker, not a search box inside the menu A search box inside the dropdown kept fighting the menu over focus, Tab, Enter repeat and IME input. The overflow row is now a menu item that opens a host picker (Command in a Popover, as the style guide prescribes for single choice with search). It ranks exact names first so any host is reachable by typing its name, mounts at most 50 results, and says how many more match. The menu keeps config order when nothing is capped. * fix(ssh): closing the host search from the keyboard returns focus to the status button The picker is anchored, not triggered, so Radix had nowhere to restore focus and it fell to the page. A keyboard close now focuses the Remote Hosts button; a click elsewhere keeps focus where the user clicked. Also drops the padding restyle on PopoverContent. * fix(ssh): the host search marks the keyboard-selected host with the palette highlight --------- Co-authored-by: m4air --- .../SshTargetList.large-config.test.tsx | 133 ++++++++++++++++ .../src/components/settings/SshTargetList.tsx | 52 +++--- .../settings-content-scroll-element.tsx | 10 ++ .../settings/settings-page-renderer.tsx | 79 ++++----- .../use-settings-interaction-controller.ts | 6 +- .../status-bar/SshHostFindPopover.tsx | 110 +++++++++++++ .../SshStatusSegment.large-host-list.test.tsx | 150 ++++++++++++++++++ .../SshStatusSegment.managed-hosts.test.tsx | 1 + .../status-bar/SshStatusSegment.tsx | 43 ++++- .../status-bar/SshTargetStatusRow.tsx | 36 +++-- .../status-bar/ssh-status-menu-rows.test.ts | 71 +++++++++ .../status-bar/ssh-status-menu-rows.ts | 71 +++++++++ .../status-bar/ssh-status-segment-copy.ts | 42 +++++ .../status-bar-copy-localization.test.tsx | 1 + .../src/i18n/en-runtime-required.json | 10 +- src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 6 + src/renderer/src/i18n/locales/fr.json | 6 + src/renderer/src/i18n/locales/ja.json | 6 + src/renderer/src/i18n/locales/ko.json | 6 + src/renderer/src/i18n/locales/zh.json | 6 + 21 files changed, 773 insertions(+), 80 deletions(-) create mode 100644 src/renderer/src/components/settings/SshTargetList.large-config.test.tsx create mode 100644 src/renderer/src/components/settings/settings-content-scroll-element.tsx create mode 100644 src/renderer/src/components/status-bar/SshHostFindPopover.tsx create mode 100644 src/renderer/src/components/status-bar/SshStatusSegment.large-host-list.test.tsx create mode 100644 src/renderer/src/components/status-bar/ssh-status-menu-rows.test.ts create mode 100644 src/renderer/src/components/status-bar/ssh-status-menu-rows.ts diff --git a/src/renderer/src/components/settings/SshTargetList.large-config.test.tsx b/src/renderer/src/components/settings/SshTargetList.large-config.test.tsx new file mode 100644 index 00000000000..0a4ac488e91 --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetList.large-config.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom +import { act, useState, type ReactElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshTarget } from '../../../../shared/ssh-types' +import { SettingsContentScrollElementProvider } from './settings-content-scroll-element' +import { SshTargetList } from './SshTargetList' + +vi.mock('./SshTargetCard', () => ({ + SshTargetCard: ({ target }: { target: SshTarget }) =>
{target.label}
+})) + +// Why 13,692: the host count from the generated ~/.ssh/config in the crash this guards. +const IMPORTED_HOST_COUNT = 13_692 +const VIEWPORT_HEIGHT_PX = 600 +const ROW_HEIGHT_PX = 52 + +function configTarget(index: number): SshTarget { + const name = `node-${String(index).padStart(5, '0')}` + return { + id: `ssh-${index}`, + label: name, + configHost: name, + host: `10.0.${Math.floor(index / 256) % 256}.${index % 256}`, + port: 22, + username: 'deploy', + source: 'ssh-config' + } +} + +function SettingsScrollerHarness({ targets }: { targets: SshTarget[] }): ReactElement { + const [scroller, setScroller] = useState(null) + return ( +
+ + undefined} + selection={{ + selected: new Set(), + click: vi.fn(), + setGroup: vi.fn(), + retain: vi.fn(), + clear: vi.fn() + }} + onConnect={vi.fn()} + onDisconnect={vi.fn()} + onTest={vi.fn()} + onEdit={vi.fn()} + onRemove={vi.fn()} + onServerChanged={vi.fn()} + /> + +
+ ) +} + +let host: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + // Why: happy-dom has no layout; the scroller is one viewport tall and every other box one row. + const heightOf = (element: Element): number => + element.classList.contains('overflow-y-auto') ? VIEWPORT_HEIGHT_PX : ROW_HEIGHT_PX + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function ( + this: HTMLElement + ) { + return heightOf(this) + }) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + return new DOMRect(0, 0, 640, heightOf(this)) + }) + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.restoreAllMocks() +}) + +function rowLabels(): string[] { + return Array.from(document.querySelectorAll('[data-ssh-row]'), (row) => row.textContent ?? '') +} + +describe('the SSH host list with a very large imported config', () => { + it('mounts only the rows near the viewport instead of one card per host', () => { + act(() => { + root.render( + configTarget(index))} + /> + ) + }) + const labels = rowLabels() + expect(labels.length).toBeGreaterThan(0) + expect(labels.length).toBeLessThan(100) + expect(labels[0]).toBe('node-00000') + }) + + it('still reaches the last host by scrolling, so no host is hidden', () => { + act(() => { + root.render( + configTarget(index))} + /> + ) + }) + const scroller = document.querySelector('[data-testid="settings-scroller"]')! + act(() => { + scroller.scrollTop = IMPORTED_HOST_COUNT * ROW_HEIGHT_PX + scroller.dispatchEvent(new Event('scroll')) + }) + expect(rowLabels()).toContain(`node-${IMPORTED_HOST_COUNT - 1}`) + expect(rowLabels()).not.toContain('node-00000') + }) + + it('renders an ordinary config in full, unwindowed', () => { + act(() => { + root.render( + + ) + }) + expect(rowLabels()).toEqual(['node-00000', 'node-00001', 'node-00002']) + expect(document.querySelector('[data-testid="virtualized-list"]')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/SshTargetList.tsx b/src/renderer/src/components/settings/SshTargetList.tsx index e2b9ec1ac3b..d413eaa11a1 100644 --- a/src/renderer/src/components/settings/SshTargetList.tsx +++ b/src/renderer/src/components/settings/SshTargetList.tsx @@ -1,6 +1,8 @@ import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' import { translate } from '@/i18n/i18n' import { Checkbox } from '../ui/checkbox' +import { VirtualizedList } from '../virtualized-list' +import { useSettingsContentScrollElement } from './settings-content-scroll-element' import type { SshTargetBusyAction } from './ssh-target-action-state' import { groupSshTargetsBySource, @@ -34,6 +36,9 @@ function groupTitle(group: SshTargetGroup): React.JSX.Element { return <>{translate('auto.components.settings.SshTargetList.fromConfig', 'From ~/.ssh/config')} } +// Why: a generated ~/.ssh/config can import >10k hosts; a card each cost GBs of DOM, so rows window. +const SSH_TARGET_ROW_ESTIMATE_PX = 52 + const NOT_LIST_CONTEXT = '[role="dialog"], [role="alertdialog"], [role="menu"], pre, textarea' /** @@ -69,6 +74,7 @@ export function SshTargetList({ onServerChanged }: SshTargetListProps): React.JSX.Element { const groups = groupSshTargetsBySource(targets) + const scrollElement = useSettingsContentScrollElement() const isMac = navigator.userAgent.includes('Mac') return ( @@ -128,25 +134,35 @@ export function SshTargetList({ -
- {group.targets.map((target) => ( - target.id} + scrollElement={scrollElement} + estimateRowHeightPx={SSH_TARGET_ROW_ESTIMATE_PX} + renderRow={(target) => ( + // Why per-row borders: windowed rows are absolutely positioned, so divide-y can't reach them. +
onRemove(target)} - onServerChanged={onServerChanged} - /> - ))} -
+ data-first-row={target === group.targets[0] ? 'true' : undefined} + className="border-t border-border/40 data-[first-row=true]:border-t-0" + > + onRemove(target)} + onServerChanged={onServerChanged} + /> +
+ )} + /> ) })} diff --git a/src/renderer/src/components/settings/settings-content-scroll-element.tsx b/src/renderer/src/components/settings/settings-content-scroll-element.tsx new file mode 100644 index 00000000000..ca094a8d54a --- /dev/null +++ b/src/renderer/src/components/settings/settings-content-scroll-element.tsx @@ -0,0 +1,10 @@ +import { createContext, useContext } from 'react' + +// Why: long Settings lists window their rows against the page's one scroller (VirtualizedList). +const SettingsContentScrollElementContext = createContext(null) + +export const SettingsContentScrollElementProvider = SettingsContentScrollElementContext.Provider + +export function useSettingsContentScrollElement(): HTMLDivElement | null { + return useContext(SettingsContentScrollElementContext) +} diff --git a/src/renderer/src/components/settings/settings-page-renderer.tsx b/src/renderer/src/components/settings/settings-page-renderer.tsx index b4c99359d87..aaea2f2dca4 100644 --- a/src/renderer/src/components/settings/settings-page-renderer.tsx +++ b/src/renderer/src/components/settings/settings-page-renderer.tsx @@ -1,4 +1,5 @@ import { ActiveSettingsSectionProvider } from './SettingsSection' +import { SettingsContentScrollElementProvider } from './settings-content-scroll-element' import { SettingsSidebar } from './SettingsSidebar' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -127,44 +128,46 @@ export function renderSettingsPage(context: SettingsRenderContext): React.JSX.El {translate('auto.components.settings.Settings.add3b97ee6', '"')} ) : ( - - {renderAgentsSettingsSection(context)} - {renderAccountsSettingsSection(context)} - {renderOrchestrationSettingsSection(context)} - {renderLinearSettingsSection(context)} - {renderDesktopCapabilitySettingsSections(context)} - {renderOrcaAccountSettingsSection(context)} - {renderSetupGuideSettingsSection(context)} - {renderGeneralSettingsSection(context)} - {renderIntegrationsSettingsSection(context)} - {renderMobileSettingsSection(context)} - {renderAutomationsSettingsSection(context)} - {renderArtifactsSettingsSection(context)} - {renderShareSkillsSettingsSection(context)} - {renderSessionHistorySettingsSection(context)} - {renderGitSettingsSection(context)} - {renderTasksSettingsSection(context)} - {renderTerminalSettingsSection(context)} - {renderQuickCommandsSettingsSection(context)} - {renderBrowserSettingsSection(context)} - {renderMobileEmulatorSettingsSection(context)} - {renderFloatingWorkspaceSettingsSection(context)} - {renderAppearanceSettingsSection(context)} - {renderChatSettingsSection(context)} - {renderInputSettingsSection(context)} - {renderNotificationsSettingsSection(context)} - {renderShortcutsSettingsSection(context)} - {renderStatsSettingsSection(context)} - {renderServersSettingsSection(context)} - {renderSshSettingsSection(context)} - {renderDeveloperPermissionsSettingsSection(context)} - {renderPrivacySettingsSection(context)} - {renderAdvancedSettingsSection(context)} - {renderDevSettingsSection(context)} - {renderExperimentalSettingsSection(context)} - {renderPluginsSettingsSection(context)} - {renderProjectSettingsSections(context)} - + + + {renderAgentsSettingsSection(context)} + {renderAccountsSettingsSection(context)} + {renderOrchestrationSettingsSection(context)} + {renderLinearSettingsSection(context)} + {renderDesktopCapabilitySettingsSections(context)} + {renderOrcaAccountSettingsSection(context)} + {renderSetupGuideSettingsSection(context)} + {renderGeneralSettingsSection(context)} + {renderIntegrationsSettingsSection(context)} + {renderMobileSettingsSection(context)} + {renderAutomationsSettingsSection(context)} + {renderArtifactsSettingsSection(context)} + {renderShareSkillsSettingsSection(context)} + {renderSessionHistorySettingsSection(context)} + {renderGitSettingsSection(context)} + {renderTasksSettingsSection(context)} + {renderTerminalSettingsSection(context)} + {renderQuickCommandsSettingsSection(context)} + {renderBrowserSettingsSection(context)} + {renderMobileEmulatorSettingsSection(context)} + {renderFloatingWorkspaceSettingsSection(context)} + {renderAppearanceSettingsSection(context)} + {renderChatSettingsSection(context)} + {renderInputSettingsSection(context)} + {renderNotificationsSettingsSection(context)} + {renderShortcutsSettingsSection(context)} + {renderStatsSettingsSection(context)} + {renderServersSettingsSection(context)} + {renderSshSettingsSection(context)} + {renderDeveloperPermissionsSettingsSection(context)} + {renderPrivacySettingsSection(context)} + {renderAdvancedSettingsSection(context)} + {renderDevSettingsSection(context)} + {renderExperimentalSettingsSection(context)} + {renderPluginsSettingsSection(context)} + {renderProjectSettingsSections(context)} + + )} diff --git a/src/renderer/src/components/settings/use-settings-interaction-controller.ts b/src/renderer/src/components/settings/use-settings-interaction-controller.ts index 33c63823c30..e580079b95f 100644 --- a/src/renderer/src/components/settings/use-settings-interaction-controller.ts +++ b/src/renderer/src/components/settings/use-settings-interaction-controller.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types' import { useAppStore } from '../../store' import { translate } from '@/i18n/i18n' @@ -50,6 +50,8 @@ export function useSettingsInteractionController(model: SettingsInteractionModel updateSettingsOrThrow } = model const contentScrollRef = useRef(null) + // Why state as well as the ref: windowed lists need a re-render once the scroller attaches. + const [contentScrollElement, setContentScrollElement] = useState(null) const searchInputRef = useRef(null) const installedFontsLoadedRef = useRef(false) const installedFontsLoadPromiseRef = useRef | null>(null) @@ -110,6 +112,7 @@ export function useSettingsInteractionController(model: SettingsInteractionModel const setContentScrollNode = useCallback((node: HTMLDivElement | null): void => { contentScrollRef.current = node + setContentScrollElement(node) if (node !== null) { return } @@ -209,6 +212,7 @@ export function useSettingsInteractionController(model: SettingsInteractionModel return { contentScrollRef, + contentScrollElement, searchInputRef, pendingNavSectionRef, pendingScrollTargetRef, diff --git a/src/renderer/src/components/status-bar/SshHostFindPopover.tsx b/src/renderer/src/components/status-bar/SshHostFindPopover.tsx new file mode 100644 index 00000000000..498b8910ea4 --- /dev/null +++ b/src/renderer/src/components/status-bar/SshHostFindPopover.tsx @@ -0,0 +1,110 @@ +import { useRef, useState, type RefObject } from 'react' +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability' +import { STATUS_LABELS, statusColor } from '../settings/SshTargetCard' +import { findSshHostMatches, type SshStatusMenuTarget } from './ssh-status-menu-rows' +import { + moreSshHostMatchesLabel, + noMatchingSshHostsLabel, + searchSshHostsLabel +} from './ssh-status-segment-copy' +import { connectSshTargetFromStatusMenu } from './SshTargetStatusRow' + +/** Searchable picker for the SSH hosts the Remote Hosts menu has no room to list. */ +export function SshHostFindPopover({ + open, + onOpenChange, + targets, + anchorRef +}: { + open: boolean + onOpenChange: (open: boolean) => void + targets: readonly SshStatusMenuTarget[] + anchorRef: RefObject +}): React.JSX.Element { + const [query, setQuery] = useState('') + const dismissedByPointerRef = useRef(false) + // Why own filtering: cmdk would mount and score an item per host, the DOM cost this avoids. + const { matches, moreCount } = open + ? findSshHostMatches(targets, query) + : { matches: [], moreCount: 0 } + + return ( + { + if (!next) { + setQuery('') + } + onOpenChange(next) + }} + > + + { + dismissedByPointerRef.current = false + }} + onPointerDownOutside={() => { + dismissedByPointerRef.current = true + }} + onCloseAutoFocus={(event) => { + // Why: an anchor is not a trigger, so Radix has nowhere to return focus; a keyboard + // close goes back to the status button, a click elsewhere keeps what was clicked. + event.preventDefault() + if (!dismissedByPointerRef.current) { + anchorRef.current?.focus() + } + }} + > + + + + {noMatchingSshHostsLabel()} + {matches.map((target) => ( + { + onOpenChange(false) + setQuery('') + void connectSshTargetFromStatusMenu(target.id) + }} + > + + {target.label} + + {STATUS_LABELS[target.status]} + + + ))} + + {moreCount > 0 ? ( +
+ {moreSshHostMatchesLabel(moreCount)} +
+ ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.large-host-list.test.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.large-host-list.test.tsx new file mode 100644 index 00000000000..dc4f7005b87 --- /dev/null +++ b/src/renderer/src/components/status-bar/SshStatusSegment.large-host-list.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom +import type { PropsWithChildren } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status' +import type { SshConnectionState, SshConnectionStatus } from '../../../../shared/ssh-types' +import type { Repo } from '../../../../shared/repo-types' +import { SshStatusSegment } from './SshStatusSegment' +import { SSH_HOST_FIND_RESULT_LIMIT, SSH_STATUS_MENU_IDLE_ROW_LIMIT } from './ssh-status-menu-rows' + +function noSettings(): GlobalSettings | null { + return null +} + +const state = { + sshTargetLabels: new Map(), + sshConnectionStates: new Map(), + repos: new Array>(), + runtimeEnvironments: new Array(), + runtimeStatusByEnvironmentId: new Map(), + settings: noSettings(), + readRuntimeHostStatusSnapshots: vi.fn(async () => undefined), + hydrateRuntimeEnvironmentStatuses: vi.fn(), + recordFeatureInteraction: vi.fn(), + openSettingsTarget: vi.fn(), + setActiveView: vi.fn() +} + +vi.mock('../../store', () => { + const useAppStore = (selector: (value: typeof state) => unknown) => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) +vi.mock('../../lib/ssh-config-passive-sync', () => ({ + syncSshConfigTargetsPassively: vi.fn(async () => undefined) +})) +vi.mock('@/components/ui/dropdown-menu', () => { + const content = ({ children }: PropsWithChildren) =>
{children}
+ return { + DropdownMenu: content, + DropdownMenuContent: content, + DropdownMenuItem: ({ children, onSelect }: PropsWithChildren<{ onSelect?: () => void }>) => ( +
+ {children} +
+ ), + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: content, + DropdownMenuSub: content, + DropdownMenuSubContent: content, + DropdownMenuSubTrigger: content + } +}) + +// Why 13,692: the host count from the generated ~/.ssh/config in the crash this guards. +const IMPORTED_HOST_COUNT = 13_692 + +function connectionState(targetId: string, status: SshConnectionStatus): SshConnectionState { + return { targetId, status, error: null, reconnectAttempt: 0 } +} + +beforeEach(() => { + vi.clearAllMocks() + state.sshTargetLabels = new Map( + Array.from({ length: IMPORTED_HOST_COUNT }, (_, index) => [ + `ssh-${index}`, + `node-${String(index).padStart(5, '0')}` + ]) + ) + state.sshConnectionStates = new Map([ + ['ssh-9000', connectionState('ssh-9000', 'connected')], + ['ssh-13000', connectionState('ssh-13000', 'reconnecting')] + ]) + state.repos = [{ id: 'repo-1', connectionId: 'ssh-12345' }] +}) + +afterEach(() => { + cleanup() +}) + +describe('the status menu with a very large imported SSH host list', () => { + it('renders a bounded number of idle host rows instead of one per imported host', () => { + render() + const connectButtons = screen.queryAllByRole('button', { name: 'Connect' }) + expect(connectButtons.length).toBeLessThanOrEqual(SSH_STATUS_MENU_IDLE_ROW_LIMIT + 1) + expect(document.body.getElementsByTagName('*').length).toBeLessThan(2_000) + }) + + it('always lists connected and in-progress hosts, wherever they sit in the list', () => { + render() + expect(screen.getByText('node-09000')).toBeTruthy() + expect(screen.getByText('node-13000')).toBeTruthy() + expect(screen.getByText('node-00000')).toBeTruthy() + expect(screen.queryByText('node-13691')).toBeNull() + }) + + it('keeps an idle host that owns a project, even deep in the config', () => { + render() + expect(screen.getByText('node-12345')).toBeTruthy() + }) + + it('offers a search for the hidden hosts that connects the exact host typed', async () => { + const connect = vi.fn(async () => undefined) + vi.stubGlobal('api', { ssh: { connect } }) + state.sshConnectionStates.set('ssh-5', connectionState('ssh-5', 'auth-failed')) + render() + const hidden = IMPORTED_HOST_COUNT - 3 - SSH_STATUS_MENU_IDLE_ROW_LIMIT + fireEvent.click(screen.getByRole('menuitem', { name: `Find among ${hidden} more SSH hosts…` })) + const search = await screen.findByRole('combobox') + fireEvent.change(search, { target: { value: 'node-13691' } }) + expect(screen.getByRole('option', { name: /node-13691/ })).toBeTruthy() + expect(screen.queryByRole('option', { name: /node-00005/ })).toBeNull() + fireEvent.keyDown(search, { key: 'Enter' }) + await vi.waitFor(() => expect(connect).toHaveBeenCalledWith({ targetId: 'ssh-13691' })) + expect(connect).toHaveBeenCalledTimes(1) + vi.unstubAllGlobals() + }) + + it('caps the search results and says how many more match', async () => { + render() + fireEvent.click(screen.getByRole('menuitem', { name: /^Find among/ })) + const search = await screen.findByRole('combobox') + fireEvent.change(search, { target: { value: 'node-1' } }) + expect(screen.getAllByRole('option').length).toBeLessThanOrEqual(SSH_HOST_FIND_RESULT_LIMIT) + expect(screen.getByText(/more matches\. Keep typing/)).toBeTruthy() + fireEvent.change(search, { target: { value: 'no-such-host' } }) + expect(screen.getByText('No SSH hosts match.')).toBeTruthy() + }) + + it('returns focus to the status button when the search is closed from the keyboard', async () => { + render() + fireEvent.click(screen.getByRole('menuitem', { name: /^Find among/ })) + const search = await screen.findByRole('combobox') + search.focus() + fireEvent.keyDown(search, { key: 'Escape' }) + await vi.waitFor(() => expect(screen.queryByRole('combobox')).toBeNull()) + const trigger = screen.getByRole('button', { name: 'Remote host connection status' }) + await vi.waitFor(() => expect(document.activeElement).toBe(trigger)) + }) + + it('offers no search when every idle host fits', () => { + state.sshTargetLabels = new Map([['ssh-1', 'solo']]) + state.sshConnectionStates = new Map() + render() + expect(screen.getByText('solo')).toBeTruthy() + expect(screen.queryByRole('menuitem', { name: /^Find/ })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.managed-hosts.test.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.managed-hosts.test.tsx index c37a28a6732..39fc5acf07f 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.managed-hosts.test.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.managed-hosts.test.tsx @@ -24,6 +24,7 @@ function noSettings(): GlobalSettings | null { let state = { sshTargetLabels: new Map(), sshConnectionStates: new Map(), + repos: [], runtimeEnvironments: new Array(), runtimeStatusByEnvironmentId: new Map(), settings: noSettings(), diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 10d373480bc..81523b42b8a 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { Loader2, MonitorSmartphone, Server, ServerOff } from 'lucide-react' import { toast } from 'sonner' import { @@ -24,7 +24,13 @@ import { runtimePairingRepairRoute } from '../../../../shared/runtime-environments' import { RuntimeHostStatusRow } from './RuntimeHostStatusRow' -import { connectedHostCountLabel, connectingHostsLabel } from './ssh-status-segment-copy' +import { + connectedHostCountLabel, + connectingHostsLabel, + moreSshHostsLabel +} from './ssh-status-segment-copy' +import { SshHostFindPopover } from './SshHostFindPopover' +import { splitSshStatusMenuRows } from './ssh-status-menu-rows' import { SshTargetStatusRow } from './SshTargetStatusRow' import { syncSshConfigTargetsPassively } from '../../lib/ssh-config-passive-sync' import { connectRuntimeHostAndReloadProjects } from './runtime-environment-explicit-connect' @@ -51,6 +57,7 @@ export function SshStatusSegment({ }): React.JSX.Element | null { const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const repos = useAppStore((s) => s.repos) const settings = useAppStore((s) => s.settings) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) @@ -60,8 +67,14 @@ export function SshStatusSegment({ const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const [menuOpen, setMenuOpen] = useState(false) + const [findOpen, setFindOpen] = useState(false) + const triggerRef = useRef(null) const [repairTarget, setRepairTarget] = useState<{ id: string; label: string } | null>(null) + const projectHostIds = useMemo( + () => new Set(repos.flatMap((repo) => (repo.connectionId ? [repo.connectionId] : []))), + [repos] + ) const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) const userManagedEnvironments = runtimeEnvironments.filter(isListedRemoteServer) // Why the registry: a managed server and its SSH host are one machine, shown on its active route. @@ -128,8 +141,11 @@ export function SshStatusSegment({ const inactiveRuntimeHosts = runtimeHostRows.filter( (host) => !isConnectedRuntimeHostState(host.state) ) - const connectedTargets = targets.filter((target) => target.status === 'connected') - const disconnectedTargets = targets.filter((target) => target.status !== 'connected') + const { + connected: connectedTargets, + inactive: disconnectedTargets, + hiddenIdleCount + } = splitSshStatusMenuRows(targets, { projectHostIds }) const connectRuntimeHost = useCallback( async (environmentId: string): Promise => { if (await connectRuntimeHostAndReloadProjects(environmentId)) { @@ -188,6 +204,7 @@ export function SshStatusSegment({ >