From 2c6dcf91c71c80cf1fce7cd0832c6773f7332790 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:07:42 +0200 Subject: [PATCH] feat(desktop): keep large gateway registries searchable --- .../chat/sidebar/connection-switcher.test.tsx | 122 ++++++++- .../app/chat/sidebar/connection-switcher.tsx | 233 +++++++++++++++--- .../settings/connections-registry.test.tsx | 47 ++++ .../src/app/settings/connections-registry.tsx | 41 ++- apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/i18n/zh.ts | 2 + .../src/lib/connection-display.test.ts | 54 ++++ apps/desktop/src/lib/connection-display.ts | 58 +++++ 9 files changed, 516 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/src/lib/connection-display.test.ts create mode 100644 apps/desktop/src/lib/connection-display.ts diff --git a/apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx b/apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx index 68612a1d5d..a490a2eaac 100644 --- a/apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/connection-switcher.test.tsx @@ -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('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() + + 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() + + 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() + + 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([ diff --git a/apps/desktop/src/app/chat/sidebar/connection-switcher.tsx b/apps/desktop/src/app/chat/sidebar/connection-switcher.tsx index c3ec15b002..567fb93c03 100644 --- a/apps/desktop/src/app/chat/sidebar/connection-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/connection-switcher.tsx @@ -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(null) + const searchInputRef = useRef(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 = { + 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" > - + { + setMenuOpen(open) + + if (!open) { + setSearchQuery('') + } + }} + open={menuOpen} + > - + - - - - + { + if (searchable && (event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === 'f') { + event.preventDefault() + event.stopPropagation() + searchInputRef.current?.focus() + searchInputRef.current?.select() + } + }} + side="top" + > + {searchable && ( + <> + { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') { + return + } + + const results = connectionListRef.current?.querySelectorAll( + '[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} + /> + + + )} + + - - - {connections.map(connection => ( - - - - ))} + + + {displayedConnections.length === 0 ? ( +
+ {t.settings.connections.noSearchResults} +
+ ) : ( + displayedConnections.map(connection => ( + + + + )) + )}
@@ -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 ( + + ) +} + +function ConnectGatewayLabel({ label }: { label: string }) { + return ( + + + ) +} + 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 ( - + {connection.label} diff --git a/apps/desktop/src/app/settings/connections-registry.test.tsx b/apps/desktop/src/app/settings/connections-registry.test.tsx index 4b4852dc50..60a26e1a68 100644 --- a/apps/desktop/src/app/settings/connections-registry.test.tsx +++ b/apps/desktop/src/app/settings/connections-registry.test.tsx @@ -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() + + 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() + + 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() diff --git a/apps/desktop/src/app/settings/connections-registry.tsx b/apps/desktop/src/app/settings/connections-registry.tsx index 55dbe367c8..7be9c30442 100644 --- a/apps/desktop/src/app/settings/connections-registry.tsx +++ b/apps/desktop/src/app/settings/connections-registry.tsx @@ -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) @@ -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 && ( + setSearchQuery(event.target.value)} + placeholder={s.searchPlaceholder} + prefix={} + size="sm" + type="search" + value={searchQuery} + /> + )} + {loading ? (
) : !registry || registry.connections.length === 0 ? ( + ) : displayedConnections.length === 0 ? ( + ) : ( - 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' diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 2aacee0930..e1bebe59de 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 5f8a32a480..41f45a1db9 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -596,6 +596,8 @@ export interface Translations { launchModeDesc: string launchPrimary: string launchLastUsed: string + searchPlaceholder: string + noSearchResults: string loadFailed: string currentPill: string primaryPill: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 3b4650d4db..ea4284a485 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -914,6 +914,8 @@ export const zh: Translations = { launchModeDesc: '选择 Hermes 启动时打开主网关,或上次成功连接的网关。', launchPrimary: '主网关', launchLastUsed: '上次使用', + searchPlaceholder: '搜索网关…', + noSearchResults: '没有与搜索匹配的网关。', loadFailed: '无法加载连接', currentPill: '当前', primaryPill: '主连接', diff --git a/apps/desktop/src/lib/connection-display.test.ts b/apps/desktop/src/lib/connection-display.test.ts new file mode 100644 index 0000000000..74ab580386 --- /dev/null +++ b/apps/desktop/src/lib/connection-display.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/lib/connection-display.ts b/apps/desktop/src/lib/connection-display.ts new file mode 100644 index 0000000000..071e1360cc --- /dev/null +++ b/apps/desktop/src/lib/connection-display.ts @@ -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>( + 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)) +}