feat(desktop): keep large gateway registries searchable

This commit is contained in:
David Dudok de Wit
2026-08-18 12:07:42 +02:00
committed by Teknium
parent 0a36fa7fbd
commit 2c6dcf91c7
9 changed files with 516 additions and 45 deletions
@@ -10,6 +10,12 @@ import { ConnectionSwitcher } from './connection-switcher'
Element.prototype.hasPointerCapture ??= () => false
Element.prototype.setPointerCapture ??= () => undefined
Element.prototype.releasePointerCapture ??= () => undefined
Element.prototype.scrollIntoView ??= () => undefined
globalThis.ResizeObserver ??= class ResizeObserver {
disconnect() {}
observe() {}
unobserve() {}
}
vi.mock('@/store/connections', () => ({
$activeConnectionId: atom<null | string>('local'),
@@ -41,7 +47,17 @@ vi.mock('@/i18n', () => ({
switchToConnection: (name: string) => `Switch to ${name}`,
connectGateway: 'Connect another Hermes gateway…'
},
settings: { connections: { title: 'Registered gateways' } }
settings: {
connections: {
noSearchResults: 'No gateways match your search.',
searchPlaceholder: 'Search gateways…',
kindCloud: 'Hermes Cloud',
kindLocal: 'Local',
kindRemote: 'Remote gateway',
kindSsh: 'SSH',
title: 'Registered gateways'
}
}
}
})
}))
@@ -166,6 +182,110 @@ describe('ConnectionSwitcher', () => {
expect(screen.getByRole('button', { name: 'Registered gateways: This device' })).toBeTruthy()
})
it('keeps small gateway lists simple and naturally sorted', () => {
$connectionsRegistry.set(
registry([
connection('zulu', 'Zulu'),
connection('local', 'This device', 'local'),
connection('studio-10', 'Studio 10'),
connection('alpha', 'alpha'),
connection('studio-2', 'Studio 2')
])
)
render(<ConnectionSwitcher onConnect={onConnect} />)
const trigger = screen.getByRole('button', { name: 'Registered gateways: This device' })
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
expect(screen.queryByPlaceholderText('Search gateways…')).toBeNull()
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)).toEqual([
'This device',
'alpha',
'Studio 2',
'Studio 10',
'Zulu'
])
})
it('adds search at eight gateways and filters stable results without moving the connect action', async () => {
$connectionsRegistry.set(
registry([
connection('zulu', 'Zulu'),
connection('local', 'This device', 'local'),
connection('studio-10', 'Studio 10'),
connection('alpha', 'Alpha'),
connection('studio-2', 'Studio 2'),
connection('work', 'Work VPS'),
connection('homelab', 'Homelab'),
connection('cloud', 'Cloud lab')
])
)
render(<ConnectionSwitcher onConnect={onConnect} />)
const trigger = screen.getByRole('button', { name: 'Registered gateways: This device' })
fireEvent.pointerDown(trigger, {
button: 0,
pointerType: 'mouse'
})
const search = screen.getByPlaceholderText('Search gateways…')
expect(screen.getByRole('menuitem', { name: 'Connect another Hermes gateway…' })).toBeTruthy()
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)).toEqual([
'This device',
'Alpha',
'Cloud lab',
'Homelab',
'Studio 2',
'Studio 10',
'Work VPS',
'Zulu'
])
fireEvent.change(search, { target: { value: 'studio 10' } })
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)).toEqual(['Studio 10'])
const result = screen.getByRole('menuitemradio', { name: 'Studio 10' })
fireEvent.keyDown(search, { key: 'ArrowDown' })
expect(globalThis.document.activeElement).toBe(result)
result.focus()
fireEvent.keyDown(result, { key: 'f', metaKey: true })
expect(globalThis.document.activeElement).toBe(search)
fireEvent.keyDown(search, { key: 'Escape' })
expect(screen.queryByPlaceholderText('Search gateways…')).toBeNull()
await waitFor(() => expect(globalThis.document.activeElement).toBe(trigger))
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
expect((screen.getByPlaceholderText('Search gateways…') as HTMLInputElement).value).toBe('')
fireEvent.click(screen.getByRole('menuitemradio', { name: 'Studio 10' }))
expect(selectConnection).toHaveBeenCalledWith('studio-10')
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
expect((screen.getByPlaceholderText('Search gateways…') as HTMLInputElement).value).toBe('')
})
it('explains an empty large-list search', () => {
$connectionsRegistry.set(
registry([
connection('local', 'This device', 'local'),
...Array.from({ length: 7 }, (_, index) => connection(`remote-${index}`, `Remote ${index}`))
])
)
render(<ConnectionSwitcher onConnect={onConnect} />)
fireEvent.pointerDown(screen.getByRole('button', { name: 'Registered gateways: This device' }), {
button: 0,
pointerType: 'mouse'
})
fireEvent.change(screen.getByPlaceholderText('Search gateways…'), { target: { value: 'missing' } })
expect(screen.getByText('No gateways match your search.')).toBeTruthy()
expect(screen.getByRole('menuitem', { name: 'Connect another Hermes gateway…' })).toBeTruthy()
expect(globalThis.document.querySelector('[data-slot="dropdown-menu-radio-group"]')?.className).toContain('h-48')
})
it('announces a pending switch in the compact source menu', () => {
$connectionsRegistry.set(
registry([
@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@@ -9,13 +9,21 @@ import {
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
dropdownMenuRow,
DropdownMenuSearch,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import type { DesktopRegistryConnection } from '@/global'
import { useI18n } from '@/i18n'
import {
CONNECTION_SEARCH_THRESHOLD,
connectionMatchesQuery,
sortConnectionsForDisplay
} from '@/lib/connection-display'
import { triggerHaptic } from '@/lib/haptics'
import { Cloud, Loader2, Monitor, Network, Terminal } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $desktopBoot } from '@/store/boot'
import {
$activeConnectionId,
@@ -33,6 +41,10 @@ export function ConnectionSwitcher({ onConnect }: { onConnect: () => void }) {
const activeConnectionId = useStore($activeConnectionId)
const boot = useStore($desktopBoot)
const pendingConnectionId = useStore($pendingConnectionId)
const [searchQuery, setSearchQuery] = useState('')
const [menuOpen, setMenuOpen] = useState(false)
const connectionListRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
void refreshConnectionsRegistry().catch(() => undefined)
@@ -56,14 +68,55 @@ export function ConnectionSwitcher({ onConnect }: { onConnect: () => void }) {
}
}, [boot.running])
const connections = registry?.connections ?? []
const connections = useMemo(() => sortConnectionsForDisplay(registry?.connections ?? []), [registry?.connections])
const activeConnection = connections.find(connection => connection.id === activeConnectionId)
const searchable = connections.length >= CONNECTION_SEARCH_THRESHOLD
const kindLabels: Record<DesktopRegistryConnection['kind'], string> = {
cloud: t.settings.connections.kindCloud,
local: t.settings.connections.kindLocal,
remote: t.settings.connections.kindRemote,
ssh: t.settings.connections.kindSsh
}
const displayedConnections = searchable
? connections.filter(connection => connectionMatchesQuery(connection, searchQuery, [kindLabels[connection.kind]]))
: connections
useEffect(() => {
if (!menuOpen || !searchable || searchQuery) {
return
}
connectionListRef.current?.querySelector('[aria-checked="true"]')?.scrollIntoView({ block: 'nearest' })
}, [activeConnectionId, menuOpen, searchQuery, searchable])
useEffect(() => {
if (!menuOpen) {
return
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return
}
event.preventDefault()
event.stopPropagation()
setMenuOpen(false)
setSearchQuery('')
}
window.addEventListener('keydown', closeOnEscape, { capture: true })
return () => window.removeEventListener('keydown', closeOnEscape, { capture: true })
}, [menuOpen])
if (connections.length <= 1) {
return null
}
const activeConnection = connections.find(connection => connection.id === activeConnectionId)
const choose = (connectionId: string) => {
triggerHaptic('selection')
const connection = connections.find(candidate => candidate.id === connectionId)
@@ -81,44 +134,98 @@ export function ConnectionSwitcher({ onConnect }: { onConnect: () => void }) {
data-slot="connection-switcher"
role="group"
>
<DropdownMenu>
<DropdownMenu
onOpenChange={open => {
setMenuOpen(open)
if (!open) {
setSearchQuery('')
}
}}
open={menuOpen}
>
<DropdownMenuTrigger asChild>
<Button
aria-label={
activeConnection
? `${t.settings.connections.title}: ${activeConnection.label}`
: t.settings.connections.title
}
className="w-full min-w-0 justify-between overflow-hidden px-1 text-(--ui-text-secondary) data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground"
size="xs"
type="button"
variant="ghost"
>
<span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
{pendingConnectionId && <Loader2 aria-hidden="true" className="size-3 shrink-0 animate-spin" />}
{activeConnection ? (
<ConnectionLabel connection={activeConnection} />
) : (
<span className="truncate">{t.settings.connections.title}</span>
)}
</span>
<Codicon aria-hidden="true" className="shrink-0 opacity-60" name="chevron-down" size="0.875rem" />
</Button>
<ConnectionSwitcherTrigger
activeConnection={activeConnection}
pending={pendingConnectionId !== null}
title={t.settings.connections.title}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-52 max-w-72" collisionPadding={8} side="top">
<DropdownMenuItem onSelect={onConnect}>
<span className="flex min-w-0 items-center gap-1.5 text-(--ui-text-secondary)">
<Codicon aria-hidden="true" name="plug" size="0.875rem" />
<span className="truncate">{t.profiles.connectGateway}</span>
</span>
<DropdownMenuContent
align="start"
className={cn('min-w-52 max-w-72', searchable && 'w-72 overflow-hidden p-0')}
collisionPadding={8}
onKeyDownCapture={event => {
if (searchable && (event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === 'f') {
event.preventDefault()
event.stopPropagation()
searchInputRef.current?.focus()
searchInputRef.current?.select()
}
}}
side="top"
>
{searchable && (
<>
<DropdownMenuSearch
className="[font-family:inherit] text-xs font-normal leading-4"
onKeyDown={event => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
return
}
const results = connectionListRef.current?.querySelectorAll<HTMLElement>(
'[role="menuitemradio"]:not([data-disabled])'
)
const target = event.key === 'ArrowDown' ? results?.item(0) : results?.item((results?.length ?? 1) - 1)
if (target) {
event.preventDefault()
event.stopPropagation()
target.focus()
}
}}
onValueChange={setSearchQuery}
placeholder={t.settings.connections.searchPlaceholder}
ref={searchInputRef}
value={searchQuery}
/>
<DropdownMenuSeparator className="m-0" />
</>
)}
<DropdownMenuItem className={searchable ? dropdownMenuRow : undefined} onSelect={onConnect}>
<ConnectGatewayLabel label={t.profiles.connectGateway} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup onValueChange={choose} value={activeConnectionId ?? ''}>
{connections.map(connection => (
<DropdownMenuRadioItem className="min-w-0" key={connection.id} value={connection.id}>
<ConnectionLabel connection={connection} />
</DropdownMenuRadioItem>
))}
<DropdownMenuSeparator className={searchable ? 'm-0' : undefined} />
<DropdownMenuRadioGroup
className={
searchable
? 'dt-portal-scrollbar h-48 max-h-[calc(var(--radix-dropdown-menu-content-available-height)-4.5rem)] overflow-y-auto p-1'
: undefined
}
onValueChange={choose}
ref={connectionListRef}
value={activeConnectionId ?? ''}
>
{displayedConnections.length === 0 ? (
<div
className="flex h-full items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)"
role="status"
>
{t.settings.connections.noSearchResults}
</div>
) : (
displayedConnections.map(connection => (
<DropdownMenuRadioItem
className={cn('min-w-0', searchable && dropdownMenuRow)}
key={connection.id}
value={connection.id}
>
<ConnectionLabel connection={connection} />
</DropdownMenuRadioItem>
))
)}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
@@ -126,6 +233,52 @@ export function ConnectionSwitcher({ onConnect }: { onConnect: () => void }) {
)
}
interface ConnectionMenuProps {
activeConnection?: DesktopRegistryConnection
pending: boolean
title: string
}
function ConnectionSwitcherTrigger({
activeConnection,
pending,
title,
...triggerProps
}: ConnectionMenuProps & React.ComponentProps<'button'>) {
return (
<Button
{...triggerProps}
aria-label={activeConnection ? `${title}: ${activeConnection.label}` : title}
className={cn(
'w-full min-w-0 justify-between overflow-hidden px-1 text-(--ui-text-secondary) data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground',
triggerProps.className
)}
size="xs"
type="button"
variant="ghost"
>
<span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
{pending && <Loader2 aria-hidden="true" className="size-3 shrink-0 animate-spin" />}
{activeConnection ? (
<ConnectionLabel connection={activeConnection} />
) : (
<span className="truncate">{title}</span>
)}
</span>
<Codicon aria-hidden="true" className="shrink-0 opacity-60" name="chevron-down" size="0.875rem" />
</Button>
)
}
function ConnectGatewayLabel({ label }: { label: string }) {
return (
<span className="flex min-w-0 items-center gap-1.5 text-(--ui-text-secondary)">
<Codicon aria-hidden="true" name="plug" size="0.875rem" />
<span className="truncate">{label}</span>
</span>
)
}
function ConnectionGlyph({ connection }: { connection: DesktopRegistryConnection }) {
const Icon =
connection.kind === 'local'
@@ -150,7 +303,7 @@ function ConnectionGlyph({ connection }: { connection: DesktopRegistryConnection
function ConnectionLabel({ connection }: { connection: DesktopRegistryConnection }) {
return (
<span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden" title={connection.label}>
<ConnectionGlyph connection={connection} />
<span className="truncate">{connection.label}</span>
</span>
@@ -163,6 +163,53 @@ describe('ConnectionsRegistrySection', () => {
expect(screen.queryByText('Open on launch')).toBeNull()
})
it('keeps search out of the way for a small registry', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
expect(screen.queryByRole('searchbox', { name: 'Search gateways…' })).toBeNull()
})
it('sorts a large registry and searches names and endpoints', async () => {
const largeRegistry: DesktopConnectionsRegistry = {
...registry,
connections: [
{
authMode: 'token',
id: 'zulu',
kind: 'remote',
label: 'Zulu',
tokenPreview: null,
tokenSet: false,
url: 'https://zulu.example.test'
},
registry.connections[0],
...Array.from({ length: 6 }, (_, index) => ({
authMode: 'token' as const,
id: `gateway-${index}`,
kind: 'remote' as const,
label: index === 0 ? 'Alpha' : `Gateway ${index}`,
tokenPreview: null,
tokenSet: false,
url: index === 4 ? 'https://studio.example.test' : `https://gateway-${index}.example.test`
}))
]
}
list.mockResolvedValueOnce(largeRegistry)
render(<ConnectionsRegistrySection />)
const search = await screen.findByRole('searchbox', { name: 'Search gateways…' })
const alpha = screen.getByText('Alpha')
const zulu = screen.getByText('Zulu')
expect(alpha.compareDocumentPosition(zulu) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.change(search, { target: { value: 'studio.example' } })
expect(screen.getByText('Gateway 4')).toBeTruthy()
expect(screen.queryByText('Alpha')).toBeNull()
})
it('tests a connection through the bridge', async () => {
render(<ConnectionsRegistrySection />)
@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
@@ -12,8 +12,13 @@ import type {
DesktopRegistryConnectionInput
} from '@/global'
import { useI18n } from '@/i18n'
import {
CONNECTION_SEARCH_THRESHOLD,
connectionMatchesQuery,
sortConnectionsForDisplay
} from '@/lib/connection-display'
import { triggerHaptic } from '@/lib/haptics'
import { Cloud, Globe, Loader2, Monitor, Pencil, Plus, RefreshCw, Terminal, Trash2 } from '@/lib/icons'
import { Cloud, Globe, Loader2, Monitor, Pencil, Plus, RefreshCw, SearchIcon, Terminal, Trash2 } from '@/lib/icons'
import { $activeConnectionId, setConnectionsRegistry } from '@/store/connections'
import { notify, notifyError } from '@/store/notifications'
@@ -214,6 +219,7 @@ export function ConnectionsRegistrySection() {
const [plainTextConfirm, setPlainTextConfirm] = useState(false)
const [launchModeBusy, setLaunchModeBusy] = useState(false)
const [updatingAll, setUpdatingAll] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
// Inline duplicate rejection from the save path (dedupe is also enforced in
// the main process, so a crafted payload can't slip past the UI check).
const [dupeError, setDupeError] = useState<null | string>(null)
@@ -468,6 +474,18 @@ export function ConnectionsRegistrySection() {
ssh: { desc: s.kindSshDesc, label: s.kindSsh }
}
const sortedConnections = useMemo(
() => sortConnectionsForDisplay(registry?.connections ?? []),
[registry?.connections]
)
const showSearch = sortedConnections.length >= CONNECTION_SEARCH_THRESHOLD
const effectiveSearchQuery = showSearch ? searchQuery : ''
const displayedConnections = sortedConnections.filter(connection =>
connectionMatchesQuery(connection, effectiveSearchQuery, [kindMeta[connection.kind].label])
)
if (!bridge) {
return null
}
@@ -500,21 +518,36 @@ export function ConnectionsRegistrySection() {
/>
)}
{!loading && showSearch && (
<Input
aria-label={s.searchPlaceholder}
containerClassName="mb-3 w-full max-w-sm"
onChange={event => setSearchQuery(event.target.value)}
placeholder={s.searchPlaceholder}
prefix={<SearchIcon className="size-3.5" />}
size="sm"
type="search"
value={searchQuery}
/>
)}
{loading ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
</div>
) : !registry || registry.connections.length === 0 ? (
<EmptyState title={s.empty} />
) : displayedConnections.length === 0 ? (
<EmptyState title={s.noSearchResults} />
) : (
registry.connections.map(conn => {
displayedConnections.map(conn => {
const Icon = KIND_ICONS[conn.kind]
const isCurrent = activeConnectionId === conn.id
const isPrimary = registry.primary === conn.id
const busy = busyId === conn.id
// Display-only: this connection is a second address for a backend
// already registered under another entry (same install_id).
const sameBackendPeer = sameBackendPeerLabel(conn, registry.connections)
const sameBackendPeer = sameBackendPeerLabel(conn, sortedConnections)
const baseDescription =
conn.kind === 'ssh'
+2
View File
@@ -710,6 +710,8 @@ export const en: Translations = {
'Choose whether Hermes opens your primary gateway or the last gateway that connected successfully.',
launchPrimary: 'Primary gateway',
launchLastUsed: 'Last used',
searchPlaceholder: 'Search gateways…',
noSearchResults: 'No gateways match your search.',
loadFailed: 'Could not load connections',
currentPill: 'Current',
primaryPill: 'Primary',
+2
View File
@@ -596,6 +596,8 @@ export interface Translations {
launchModeDesc: string
launchPrimary: string
launchLastUsed: string
searchPlaceholder: string
noSearchResults: string
loadFailed: string
currentPill: string
primaryPill: string
+2
View File
@@ -914,6 +914,8 @@ export const zh: Translations = {
launchModeDesc: '选择 Hermes 启动时打开主网关,或上次成功连接的网关。',
launchPrimary: '主网关',
launchLastUsed: '上次使用',
searchPlaceholder: '搜索网关…',
noSearchResults: '没有与搜索匹配的网关。',
loadFailed: '无法加载连接',
currentPill: '当前',
primaryPill: '主连接',
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import type { DesktopRegistryConnection } from '@/global'
import { connectionMatchesQuery, sortConnectionsForDisplay } from './connection-display'
const connection = (
id: string,
label: string,
kind: DesktopRegistryConnection['kind'] = 'remote'
): DesktopRegistryConnection => ({ id, kind, label, tokenPreview: null, tokenSet: false })
describe('connection display helpers', () => {
it('anchors local first and sorts labels case-insensitively with numeric order', () => {
const sorted = sortConnectionsForDisplay([
connection('remote-10', 'Studio 10'),
connection('zulu', 'zulu'),
connection('remote-2', 'studio 2'),
connection('local', 'This device', 'local'),
connection('alpha', 'Alpha')
])
expect(sorted.map(item => item.id)).toEqual(['local', 'alpha', 'remote-2', 'remote-10', 'zulu'])
})
it('does not mutate registry order', () => {
const connections = [connection('zulu', 'Zulu'), connection('alpha', 'Alpha')]
sortConnectionsForDisplay(connections)
expect(connections.map(item => item.id)).toEqual(['zulu', 'alpha'])
})
it('matches labels, transport details, localized aliases, and accents', () => {
const gateway: DesktopRegistryConnection = {
...connection('studio', 'Studio Genève', 'ssh'),
host: 'studio.example.test',
org: 'Editorial',
port: 2222,
remoteProfile: 'production',
user: 'hermes'
}
expect(connectionMatchesQuery(gateway, 'GENEVE')).toBe(true)
expect(connectionMatchesQuery(gateway, 'studio 2222')).toBe(true)
expect(connectionMatchesQuery(gateway, 'ssh studio')).toBe(true)
expect(connectionMatchesQuery(gateway, 'ssh')).toBe(true)
expect(connectionMatchesQuery(gateway, 'example.test')).toBe(true)
expect(connectionMatchesQuery(gateway, '2222')).toBe(true)
expect(connectionMatchesQuery(gateway, 'editorial production')).toBe(true)
expect(connectionMatchesQuery(gateway, 'distant', ['Passerelle distante'])).toBe(true)
expect(connectionMatchesQuery(gateway, 'homelab')).toBe(false)
})
})
@@ -0,0 +1,58 @@
import type { DesktopRegistryConnection } from '@/global'
export const CONNECTION_SEARCH_THRESHOLD = 8
const connectionLabelCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base'
})
/** Local stays anchored; every other gateway has one stable, human-readable order. */
export function sortConnectionsForDisplay<T extends Pick<DesktopRegistryConnection, 'id' | 'kind' | 'label'>>(
connections: readonly T[]
): T[] {
return [...connections].sort((left, right) => {
const localOrder = Number(right.kind === 'local') - Number(left.kind === 'local')
return (
localOrder ||
connectionLabelCollator.compare(left.label, right.label) ||
connectionLabelCollator.compare(left.id, right.id)
)
})
}
function normalizeSearchText(value: string): string {
return value.normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase()
}
/** Search non-secret details users can see or reasonably remember about a gateway. */
export function connectionMatchesQuery(
connection: DesktopRegistryConnection,
query: string,
aliases: readonly string[] = []
): boolean {
const needles = normalizeSearchText(query).trim().split(/\s+/).filter(Boolean)
if (needles.length === 0) {
return true
}
const haystack = [
connection.label,
connection.kind,
connection.url,
connection.host,
connection.user,
connection.port == null ? null : String(connection.port),
connection.org,
connection.remoteProfile,
...aliases
]
.filter((value): value is string => Boolean(value))
.join(' ')
const normalizedHaystack = normalizeSearchText(haystack)
return needles.every(needle => normalizedHaystack.includes(needle))
}