feat(desktop): keep large gateway registries searchable
This commit is contained in:
committed by
Teknium
parent
0a36fa7fbd
commit
2c6dcf91c7
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -596,6 +596,8 @@ export interface Translations {
|
||||
launchModeDesc: string
|
||||
launchPrimary: string
|
||||
launchLastUsed: string
|
||||
searchPlaceholder: string
|
||||
noSearchResults: string
|
||||
loadFailed: string
|
||||
currentPill: string
|
||||
primaryPill: string
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user