mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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 <m4air@Mac.localdomain>
This commit is contained in:
@@ -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 }) => <div data-ssh-row>{target.label}</div>
|
||||
}))
|
||||
|
||||
// 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<HTMLDivElement | null>(null)
|
||||
return (
|
||||
<div className="overflow-y-auto" data-testid="settings-scroller" ref={setScroller}>
|
||||
<SettingsContentScrollElementProvider value={scroller}>
|
||||
<SshTargetList
|
||||
targets={targets}
|
||||
totalBySource={{ 'ssh-config': targets.length, manual: 0 }}
|
||||
connectionStates={new Map()}
|
||||
testingIds={new Set()}
|
||||
busyActionForTarget={() => 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()}
|
||||
/>
|
||||
</SettingsContentScrollElementProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<SettingsScrollerHarness
|
||||
targets={Array.from({ length: IMPORTED_HOST_COUNT }, (_, index) => 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(
|
||||
<SettingsScrollerHarness
|
||||
targets={Array.from({ length: IMPORTED_HOST_COUNT }, (_, index) => configTarget(index))}
|
||||
/>
|
||||
)
|
||||
})
|
||||
const scroller = document.querySelector<HTMLDivElement>('[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(
|
||||
<SettingsScrollerHarness targets={[configTarget(0), configTarget(1), configTarget(2)]} />
|
||||
)
|
||||
})
|
||||
expect(rowLabels()).toEqual(['node-00000', 'node-00001', 'node-00002'])
|
||||
expect(document.querySelector('[data-testid="virtualized-list"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40">
|
||||
{group.targets.map((target) => (
|
||||
<SshTargetCard
|
||||
<VirtualizedList
|
||||
rows={group.targets}
|
||||
getRowKey={(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.
|
||||
<div
|
||||
key={target.id}
|
||||
target={target}
|
||||
state={connectionStates.get(target.id)}
|
||||
testing={testingIds.has(target.id)}
|
||||
busyAction={busyActionForTarget(target.id)}
|
||||
selected={selection.selected.has(target.id)}
|
||||
onSelectClick={selection.click}
|
||||
onConnect={onConnect}
|
||||
onDisconnect={onDisconnect}
|
||||
onTest={onTest}
|
||||
onEdit={onEdit}
|
||||
onRemove={() => onRemove(target)}
|
||||
onServerChanged={onServerChanged}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
data-first-row={target === group.targets[0] ? 'true' : undefined}
|
||||
className="border-t border-border/40 data-[first-row=true]:border-t-0"
|
||||
>
|
||||
<SshTargetCard
|
||||
target={target}
|
||||
state={connectionStates.get(target.id)}
|
||||
testing={testingIds.has(target.id)}
|
||||
busyAction={busyActionForTarget(target.id)}
|
||||
selected={selection.selected.has(target.id)}
|
||||
onSelectClick={selection.click}
|
||||
onConnect={onConnect}
|
||||
onDisconnect={onDisconnect}
|
||||
onTest={onTest}
|
||||
onEdit={onEdit}
|
||||
onRemove={() => onRemove(target)}
|
||||
onServerChanged={onServerChanged}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null)
|
||||
|
||||
export const SettingsContentScrollElementProvider = SettingsContentScrollElementContext.Provider
|
||||
|
||||
export function useSettingsContentScrollElement(): HTMLDivElement | null {
|
||||
return useContext(SettingsContentScrollElementContext)
|
||||
}
|
||||
@@ -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', '"')}
|
||||
</div>
|
||||
) : (
|
||||
<ActiveSettingsSectionProvider value={model.activeSectionId}>
|
||||
{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)}
|
||||
</ActiveSettingsSectionProvider>
|
||||
<SettingsContentScrollElementProvider value={interactions.contentScrollElement}>
|
||||
<ActiveSettingsSectionProvider value={model.activeSectionId}>
|
||||
{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)}
|
||||
</ActiveSettingsSectionProvider>
|
||||
</SettingsContentScrollElementProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null)
|
||||
// Why state as well as the ref: windowed lists need a re-render once the scroller attaches.
|
||||
const [contentScrollElement, setContentScrollElement] = useState<HTMLDivElement | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const installedFontsLoadedRef = useRef(false)
|
||||
const installedFontsLoadPromiseRef = useRef<Promise<void> | 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,
|
||||
|
||||
@@ -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<HTMLButtonElement | null>
|
||||
}): 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 (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
setQuery('')
|
||||
}
|
||||
onOpenChange(next)
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor virtualRef={anchorRef} />
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-[min(20rem,calc(100vw-1rem))]"
|
||||
onOpenAutoFocus={() => {
|
||||
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()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder={searchSshHostsLabel()}
|
||||
aria-label={searchSshHostsLabel()}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{noMatchingSshHostsLabel()}</CommandEmpty>
|
||||
{matches.map((target) => (
|
||||
<CommandItem
|
||||
key={target.id}
|
||||
value={target.id}
|
||||
selection="palette"
|
||||
disabled={!canConnectSshStatus(target.status)}
|
||||
onSelect={() => {
|
||||
onOpenChange(false)
|
||||
setQuery('')
|
||||
void connectSshTargetFromStatusMenu(target.id)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn('size-1.5 shrink-0 rounded-full', statusColor(target.status))}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{target.label}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{STATUS_LABELS[target.status]}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
{moreCount > 0 ? (
|
||||
<div className="border-t border-border px-3 py-2 text-xs text-muted-foreground">
|
||||
{moreSshHostMatchesLabel(moreCount)}
|
||||
</div>
|
||||
) : null}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string>(),
|
||||
sshConnectionStates: new Map<string, SshConnectionState>(),
|
||||
repos: new Array<Pick<Repo, 'id' | 'connectionId'>>(),
|
||||
runtimeEnvironments: new Array<PublicKnownRuntimeEnvironment>(),
|
||||
runtimeStatusByEnvironmentId: new Map<string, RuntimeEnvironmentStatus>(),
|
||||
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) => <div>{children}</div>
|
||||
return {
|
||||
DropdownMenu: content,
|
||||
DropdownMenuContent: content,
|
||||
DropdownMenuItem: ({ children, onSelect }: PropsWithChildren<{ onSelect?: () => void }>) => (
|
||||
<div role="menuitem" onClick={onSelect}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
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(<SshStatusSegment compact={false} iconOnly={false} />)
|
||||
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(<SshStatusSegment compact={false} iconOnly={false} />)
|
||||
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(<SshStatusSegment compact={false} iconOnly={false} />)
|
||||
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(<SshStatusSegment compact={false} iconOnly={false} />)
|
||||
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(<SshStatusSegment compact={false} iconOnly={false} />)
|
||||
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(<SshStatusSegment compact={false} iconOnly={false} />)
|
||||
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(<SshStatusSegment compact={false} iconOnly={false} />)
|
||||
expect(screen.getByText('solo')).toBeTruthy()
|
||||
expect(screen.queryByRole('menuitem', { name: /^Find/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,7 @@ function noSettings(): GlobalSettings | null {
|
||||
let state = {
|
||||
sshTargetLabels: new Map<string, string>(),
|
||||
sshConnectionStates: new Map<string, SshConnectionState>(),
|
||||
repos: [],
|
||||
runtimeEnvironments: new Array<PublicKnownRuntimeEnvironment>(),
|
||||
runtimeStatusByEnvironmentId: new Map<string, RuntimeEnvironmentStatus>(),
|
||||
settings: noSettings(),
|
||||
|
||||
@@ -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<HTMLButtonElement>(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<void> => {
|
||||
if (await connectRuntimeHostAndReloadProjects(environmentId)) {
|
||||
@@ -188,6 +204,7 @@ export function SshStatusSegment({
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
|
||||
aria-label={translate(
|
||||
@@ -244,6 +261,12 @@ export function SshStatusSegment({
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-[min(20rem,calc(100vw-1rem))]"
|
||||
onCloseAutoFocus={(event) => {
|
||||
// Why: the host search takes focus as the menu closes; the trigger must not take it back.
|
||||
if (findOpen) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="px-2 pt-1.5 pb-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{translate('auto.components.status.bar.SshStatusSegment.6e8a9a4242', 'Remote Hosts')}
|
||||
@@ -280,6 +303,12 @@ export function SshStatusSegment({
|
||||
{disconnectedTargets.map((t) => (
|
||||
<SshTargetStatusRow key={t.id} targetId={t.id} label={t.label} status={t.status} />
|
||||
))}
|
||||
{hiddenIdleCount > 0 ? (
|
||||
// Why a search and not a Settings link: web clients connect hosts here but have no SSH pane.
|
||||
<DropdownMenuItem onSelect={() => setFindOpen(true)}>
|
||||
{moreSshHostsLabel(hiddenIdleCount)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
@@ -294,6 +323,12 @@ export function SshStatusSegment({
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
<SshHostFindPopover
|
||||
open={findOpen}
|
||||
onOpenChange={setFindOpen}
|
||||
targets={targets}
|
||||
anchorRef={triggerRef}
|
||||
/>
|
||||
{repairTarget && (
|
||||
<RuntimeRepairDialog
|
||||
open
|
||||
|
||||
@@ -16,6 +16,26 @@ import {
|
||||
} from '@/ssh/ssh-connect-in-flight'
|
||||
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
|
||||
/** One connect attempt from the hosts menu: a row's Connect button or a pick in its host search. */
|
||||
export async function connectSshTargetFromStatusMenu(targetId: string): Promise<void> {
|
||||
if (isSshConnectInFlight(targetId)) {
|
||||
return
|
||||
}
|
||||
beginSshConnect(targetId)
|
||||
try {
|
||||
await window.api.ssh.connect({ targetId })
|
||||
useAppStore.getState().recordFeatureInteraction('ssh')
|
||||
} catch (err) {
|
||||
toastSshConnectFailure(
|
||||
targetId,
|
||||
err,
|
||||
translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed')
|
||||
)
|
||||
} finally {
|
||||
endSshConnect(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
export function SshTargetStatusRow({
|
||||
targetId,
|
||||
label,
|
||||
@@ -33,27 +53,15 @@ export function SshTargetStatusRow({
|
||||
const connectInFlight = useSshConnectInFlight(targetId)
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
if (isSshConnectInFlight(targetId)) {
|
||||
return
|
||||
}
|
||||
beginSshConnect(targetId)
|
||||
setBusy(true)
|
||||
try {
|
||||
await window.api.ssh.connect({ targetId })
|
||||
recordFeatureInteraction('ssh')
|
||||
} catch (err) {
|
||||
toastSshConnectFailure(
|
||||
targetId,
|
||||
err,
|
||||
translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed')
|
||||
)
|
||||
await connectSshTargetFromStatusMenu(targetId)
|
||||
} finally {
|
||||
endSshConnect(targetId)
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
}, [mountedRef, recordFeatureInteraction, targetId])
|
||||
}, [mountedRef, targetId])
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setBusy(true)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findSshHostMatches,
|
||||
splitSshStatusMenuRows,
|
||||
type SshStatusMenuTarget
|
||||
} from './ssh-status-menu-rows'
|
||||
|
||||
function target(id: string, status: SshStatusMenuTarget['status']): SshStatusMenuTarget {
|
||||
return { id, label: id, status }
|
||||
}
|
||||
|
||||
describe('splitSshStatusMenuRows', () => {
|
||||
it('keeps every connected and in-progress host and caps only idle ones', () => {
|
||||
const rows = splitSshStatusMenuRows(
|
||||
[
|
||||
target('a', 'disconnected'),
|
||||
target('b', 'disconnected'),
|
||||
target('c', 'disconnected'),
|
||||
target('d', 'auth-failed'),
|
||||
target('e', 'connected')
|
||||
],
|
||||
{ idleRowLimit: 1 }
|
||||
)
|
||||
expect(rows.connected.map((row) => row.id)).toEqual(['e'])
|
||||
expect(rows.inactive.map((row) => row.id)).toEqual(['d', 'a'])
|
||||
expect(rows.hiddenIdleCount).toBe(2)
|
||||
})
|
||||
|
||||
it('fills the idle cap with hosts that own projects before the rest of the config', () => {
|
||||
const rows = splitSshStatusMenuRows(
|
||||
[target('a', 'disconnected'), target('b', 'disconnected'), target('c', 'disconnected')],
|
||||
{ projectHostIds: new Set(['c']), idleRowLimit: 2 }
|
||||
)
|
||||
expect(rows.inactive.map((row) => row.id)).toEqual(['c', 'a'])
|
||||
expect(rows.hiddenIdleCount).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps config order and hides nothing at or under the limit', () => {
|
||||
const rows = splitSshStatusMenuRows(
|
||||
[target('a', 'disconnected'), target('b', 'error'), target('c', 'disconnected')],
|
||||
{ projectHostIds: new Set(['c']), idleRowLimit: 2 }
|
||||
)
|
||||
expect(rows.hiddenIdleCount).toBe(0)
|
||||
expect(rows.inactive.map((row) => row.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('findSshHostMatches', () => {
|
||||
it('ranks an exact name ahead of any number of prefix and substring matches', () => {
|
||||
const targets = [
|
||||
...Array.from({ length: 30 }, (_, index) => target(`prod-${index}`, 'disconnected')),
|
||||
target('east-prod', 'disconnected'),
|
||||
target('PROD', 'disconnected')
|
||||
]
|
||||
const { matches, moreCount } = findSshHostMatches(targets, ' prod ', 5)
|
||||
expect(matches[0]?.id).toBe('PROD')
|
||||
expect(matches).toHaveLength(5)
|
||||
expect(moreCount).toBe(27)
|
||||
expect(findSshHostMatches(targets, 'east', 5).matches.map((row) => row.id)).toEqual([
|
||||
'east-prod'
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves out connected hosts, which the menu already lists', () => {
|
||||
const { matches } = findSshHostMatches(
|
||||
[target('web-1', 'connected'), target('web-2', 'disconnected')],
|
||||
'web'
|
||||
)
|
||||
expect(matches.map((row) => row.id)).toEqual(['web-2'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
|
||||
// Why: a generated ~/.ssh/config can import >10k hosts; a row each made one open cost GBs of DOM.
|
||||
export const SSH_STATUS_MENU_IDLE_ROW_LIMIT = 25
|
||||
export const SSH_HOST_FIND_RESULT_LIMIT = 50
|
||||
|
||||
export type SshStatusMenuTarget = { id: string; label: string; status: SshConnectionStatus }
|
||||
|
||||
export type SshStatusMenuRows = {
|
||||
connected: SshStatusMenuTarget[]
|
||||
inactive: SshStatusMenuTarget[]
|
||||
/** Idle hosts the limit left out; the menu offers a search for them. */
|
||||
hiddenIdleCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected and busy hosts always get a row. Under the idle limit the rest keep config order; past
|
||||
* it, hosts that own projects fill the limit first so it keeps the machines the user works on.
|
||||
*/
|
||||
export function splitSshStatusMenuRows(
|
||||
targets: readonly SshStatusMenuTarget[],
|
||||
options: { projectHostIds?: ReadonlySet<string>; idleRowLimit?: number } = {}
|
||||
): SshStatusMenuRows {
|
||||
const { projectHostIds, idleRowLimit = SSH_STATUS_MENU_IDLE_ROW_LIMIT } = options
|
||||
const connected = targets.filter((target) => target.status === 'connected')
|
||||
const inactive = targets.filter((target) => target.status !== 'connected')
|
||||
const idle = inactive.filter((target) => target.status === 'disconnected')
|
||||
if (idle.length <= idleRowLimit) {
|
||||
return { connected, inactive, hiddenIdleCount: 0 }
|
||||
}
|
||||
const busy = inactive.filter((target) => target.status !== 'disconnected')
|
||||
const shownIdle = [
|
||||
...idle.filter((target) => projectHostIds?.has(target.id)),
|
||||
...idle.filter((target) => !projectHostIds?.has(target.id))
|
||||
].slice(0, idleRowLimit)
|
||||
return {
|
||||
connected,
|
||||
inactive: [...busy, ...shownIdle],
|
||||
hiddenIdleCount: idle.length - shownIdle.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not-connected hosts matching `query`, exact names first, then prefixes, then substrings, so any
|
||||
* host is reachable by typing its full name however many others share its prefix.
|
||||
*/
|
||||
export function findSshHostMatches(
|
||||
targets: readonly SshStatusMenuTarget[],
|
||||
query: string,
|
||||
limit = SSH_HOST_FIND_RESULT_LIMIT
|
||||
): { matches: SshStatusMenuTarget[]; moreCount: number } {
|
||||
const needle = query.trim().toLowerCase()
|
||||
const exact: SshStatusMenuTarget[] = []
|
||||
const prefix: SshStatusMenuTarget[] = []
|
||||
const contains: SshStatusMenuTarget[] = []
|
||||
for (const target of targets) {
|
||||
if (target.status === 'connected') {
|
||||
continue
|
||||
}
|
||||
const label = target.label.toLowerCase()
|
||||
if (label === needle) {
|
||||
exact.push(target)
|
||||
} else if (label.startsWith(needle)) {
|
||||
prefix.push(target)
|
||||
} else if (label.includes(needle)) {
|
||||
contains.push(target)
|
||||
}
|
||||
}
|
||||
const all = [...exact, ...prefix, ...contains]
|
||||
return { matches: all.slice(0, limit), moreCount: Math.max(0, all.length - limit) }
|
||||
}
|
||||
@@ -17,3 +17,45 @@ export function connectedHostCountLabel(count: number): string {
|
||||
export function connectingHostsLabel(): string {
|
||||
return translate('auto.components.status.bar.SshStatusSegment.connecting', 'Connecting…')
|
||||
}
|
||||
|
||||
export function moreSshHostsLabel(count: number): string {
|
||||
return count === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.SshStatusSegment.moreSshHosts_one',
|
||||
'Find {{count}} more SSH host…',
|
||||
{ count }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.SshStatusSegment.moreSshHosts_other',
|
||||
'Find among {{count}} more SSH hosts…',
|
||||
{ count }
|
||||
)
|
||||
}
|
||||
|
||||
export function searchSshHostsLabel(): string {
|
||||
return translate(
|
||||
'auto.components.status.bar.SshStatusSegment.searchSshHosts',
|
||||
'Search SSH hosts…'
|
||||
)
|
||||
}
|
||||
|
||||
export function noMatchingSshHostsLabel(): string {
|
||||
return translate(
|
||||
'auto.components.status.bar.SshStatusSegment.noMatchingSshHosts',
|
||||
'No SSH hosts match.'
|
||||
)
|
||||
}
|
||||
|
||||
export function moreSshHostMatchesLabel(count: number): string {
|
||||
return count === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.SshStatusSegment.moreSshHostMatches_one',
|
||||
'{{count}} more match. Keep typing to narrow it down.',
|
||||
{ count }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.SshStatusSegment.moreSshHostMatches_other',
|
||||
'{{count}} more matches. Keep typing to narrow them down.',
|
||||
{ count }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('../../store', () => {
|
||||
const state = (): Record<string, unknown> => ({
|
||||
...storeState,
|
||||
settings: null,
|
||||
repos: [],
|
||||
runtimeEnvironments: [],
|
||||
runtimeStatusByEnvironmentId: new Map(),
|
||||
setRuntimeEnvironmentStatus: vi.fn(),
|
||||
|
||||
+6
-4
@@ -1904,7 +1904,6 @@
|
||||
"outcome": {
|
||||
"activeServer": "Not done: this server is the default host for new projects. Choose another one in Advanced before stopping it."
|
||||
},
|
||||
"row": {},
|
||||
"title": "Orca on your SSH hosts"
|
||||
},
|
||||
"mobile": {
|
||||
@@ -1936,7 +1935,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"serverList": {},
|
||||
"settingOwnership": {
|
||||
"clientDefaultProjectScopes": "Client default + project scopes",
|
||||
"terminalQuickCommands": "Commands are saved on this client, then scoped globally or to a project setup so they run from the selected terminal context."
|
||||
@@ -2523,7 +2521,11 @@
|
||||
"connectedHostCount_other": "{{count}} hosts",
|
||||
"d09ec41831": "Remote Hosts",
|
||||
"fbb3f9f05e": "conflict",
|
||||
"fd9a3c600e": "error"
|
||||
"fd9a3c600e": "error",
|
||||
"moreSshHostMatches_one": "{{count}} more match. Keep typing to narrow it down.",
|
||||
"moreSshHostMatches_other": "{{count}} more matches. Keep typing to narrow them down.",
|
||||
"moreSshHosts_one": "Find {{count}} more SSH host…",
|
||||
"moreSshHosts_other": "Find among {{count}} more SSH hosts…"
|
||||
},
|
||||
"StatusBar": {
|
||||
"06741a2f3d": "Open MiniMax usage details",
|
||||
@@ -3262,8 +3264,8 @@
|
||||
"notDoneAnswer": "Your answer was not sent.",
|
||||
"notDoneCommand": "The command didn't run.",
|
||||
"notDoneGoal": "The goal wasn't changed.",
|
||||
"notDoneQueuedEdit": "Your edit wasn't saved.",
|
||||
"notDoneOption": "The setting wasn't changed.",
|
||||
"notDoneQueuedEdit": "Your edit wasn't saved.",
|
||||
"notDoneReadHistory": "This chat's history couldn't be loaded.",
|
||||
"notDoneSend": "Your message was not sent.",
|
||||
"notDoneStop": "The agent wasn't stopped.",
|
||||
|
||||
@@ -4084,7 +4084,13 @@
|
||||
"runtime_workspace_window_closed": "Workspace window closed",
|
||||
"connectedHostCount_one": "{{count}} host",
|
||||
"connectedHostCount_other": "{{count}} hosts",
|
||||
"connecting": "Connecting…"
|
||||
"connecting": "Connecting…",
|
||||
"searchSshHosts": "Search SSH hosts…",
|
||||
"noMatchingSshHosts": "No SSH hosts match.",
|
||||
"moreSshHostMatches_one": "{{count}} more match. Keep typing to narrow it down.",
|
||||
"moreSshHostMatches_other": "{{count}} more matches. Keep typing to narrow them down.",
|
||||
"moreSshHosts_one": "Find {{count}} more SSH host…",
|
||||
"moreSshHosts_other": "Find among {{count}} more SSH hosts…"
|
||||
},
|
||||
"StatusBar": {
|
||||
"9659e38343": "Ports",
|
||||
|
||||
@@ -3822,6 +3822,12 @@
|
||||
"connectedHostCount_one": "{{count}} host",
|
||||
"connectedHostCount_other": "{{count}} hosts",
|
||||
"connecting": "Conectando…",
|
||||
"searchSshHosts": "Buscar hosts SSH…",
|
||||
"noMatchingSshHosts": "Ningún host SSH coincide.",
|
||||
"moreSshHostMatches_one": "{{count}} coincidencia más. Sigue escribiendo para acotar.",
|
||||
"moreSshHostMatches_other": "{{count}} coincidencias más. Sigue escribiendo para acotar.",
|
||||
"moreSshHosts_one": "Buscar {{count}} host SSH más…",
|
||||
"moreSshHosts_other": "Buscar entre {{count}} hosts SSH más…",
|
||||
"runtime_unavailable_transport_up": "Orca no disponible",
|
||||
"runtime_workspace_window_closed": "Ventana del espacio de trabajo cerrada"
|
||||
},
|
||||
|
||||
@@ -3936,6 +3936,12 @@
|
||||
"connectedHostCount_one": "{{count}} hôte",
|
||||
"connectedHostCount_other": "{{count}} hôtes",
|
||||
"connecting": "Connexion…",
|
||||
"searchSshHosts": "Rechercher des hôtes SSH…",
|
||||
"noMatchingSshHosts": "Aucun hôte SSH ne correspond.",
|
||||
"moreSshHostMatches_one": "{{count}} autre résultat. Continuez à taper pour affiner.",
|
||||
"moreSshHostMatches_other": "{{count}} autres résultats. Continuez à taper pour affiner.",
|
||||
"moreSshHosts_one": "Trouver {{count}} autre hôte SSH…",
|
||||
"moreSshHosts_other": "Chercher parmi {{count}} autres hôtes SSH…",
|
||||
"runtime_unavailable_transport_up": "Orque indisponible"
|
||||
},
|
||||
"StatusBar": {
|
||||
|
||||
@@ -3827,6 +3827,12 @@
|
||||
"connectedHostCount_one": "{{count}} 台のホスト",
|
||||
"connectedHostCount_other": "{{count}} 台のホスト",
|
||||
"connecting": "接続中…",
|
||||
"searchSshHosts": "SSH ホストを検索…",
|
||||
"noMatchingSshHosts": "一致する SSH ホストはありません。",
|
||||
"moreSshHostMatches_one": "ほかに {{count}} 件一致しています。入力を続けて絞り込んでください。",
|
||||
"moreSshHostMatches_other": "ほかに {{count}} 件一致しています。入力を続けて絞り込んでください。",
|
||||
"moreSshHosts_one": "他 {{count}} 台の SSH ホストから検索…",
|
||||
"moreSshHosts_other": "他 {{count}} 台の SSH ホストから検索…",
|
||||
"runtime_unavailable_transport_up": "Orca は利用できません",
|
||||
"runtime_workspace_window_closed": "ワークスペースウィンドウを閉じた"
|
||||
},
|
||||
|
||||
@@ -3827,6 +3827,12 @@
|
||||
"connectedHostCount_one": "호스트 {{count}}개",
|
||||
"connectedHostCount_other": "호스트 {{count}}개",
|
||||
"connecting": "연결 중…",
|
||||
"searchSshHosts": "SSH 호스트 검색…",
|
||||
"noMatchingSshHosts": "일치하는 SSH 호스트가 없습니다.",
|
||||
"moreSshHostMatches_one": "{{count}}개 더 일치합니다. 계속 입력해 범위를 좁히세요.",
|
||||
"moreSshHostMatches_other": "{{count}}개 더 일치합니다. 계속 입력해 범위를 좁히세요.",
|
||||
"moreSshHosts_one": "SSH 호스트 {{count}}개 더 찾기…",
|
||||
"moreSshHosts_other": "SSH 호스트 {{count}}개 중에서 찾기…",
|
||||
"runtime_unavailable_transport_up": "Orca를 사용할 수 없습니다.",
|
||||
"runtime_workspace_window_closed": "워크스페이스 창이 닫혔습니다."
|
||||
},
|
||||
|
||||
@@ -3827,6 +3827,12 @@
|
||||
"connectedHostCount_one": "{{count}} 个主机",
|
||||
"connectedHostCount_other": "{{count}} 个主机",
|
||||
"connecting": "正在连接…",
|
||||
"searchSshHosts": "搜索 SSH 主机…",
|
||||
"noMatchingSshHosts": "没有匹配的 SSH 主机。",
|
||||
"moreSshHostMatches_one": "还有 {{count}} 个匹配项,继续输入以缩小范围。",
|
||||
"moreSshHostMatches_other": "还有 {{count}} 个匹配项,继续输入以缩小范围。",
|
||||
"moreSshHosts_one": "在另外 {{count}} 个 SSH 主机中查找…",
|
||||
"moreSshHosts_other": "在另外 {{count}} 个 SSH 主机中查找…",
|
||||
"runtime_unavailable_transport_up": "Orca 不可用",
|
||||
"runtime_workspace_window_closed": "工作区窗口已关闭"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user