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({ >