/** * Hosts tab: the host table with search (debounced through listHosts), * add/edit/delete/test actions, ~/.ssh/config import, and a connect action * that hands the alias to the terminal tab via onConnect. */ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import type { SshApi } from '../api.ts' import type { ImportSkipBlock, ImportSkipReason, SshHostSummary, TestResult } from '../../protocol.ts' import type { SshKey } from '../locales.ts' import { errorMessage, tt } from './helpers.ts' import { HostFormDialog } from './HostFormDialog.tsx' import css from './panel.module.css' /** Hosts tab props. */ export interface HostsTabProps { api: SshApi /** Connect the given alias in the terminal tab. */ onConnect: (alias: string) => void } /** The host-form dialog invocation. */ type DialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary } /** Skipped-block rows rendered before the "and N more" summary. */ const IMPORT_SKIP_LIMIT = 8 /** Locale key per import skip reason (no dynamic key concatenation). */ const IMPORT_REASON_KEY: Record = { wildcard: 'import.reason.wildcard', existing: 'import.reason.existing', match: 'import.reason.match', invalid: 'import.reason.invalid', } /** Host list grouping modes (#379). */ export type HostGroupBy = 'none' | 'environment' | 'tags' /** One collapsible group section of the grouped host list. */ export interface HostGroup { /** Group key: the environment name, one tag, or '' for the ungrouped bucket. */ key: string hosts: SshHostSummary[] } /** * Bucket hosts into collapsible groups (#379). Grouping by tags places a * multi-tag host in every one of its tag groups (folder view); hosts without * the grouping key land in the '' bucket, which always sorts last. Groups * sort alphabetically; host order inside a group follows the API listing. */ export function groupHosts(hosts: SshHostSummary[], groupBy: HostGroupBy): HostGroup[] { if (groupBy === 'none') return [{ key: '', hosts }] const buckets = new Map() const push = (key: string, host: SshHostSummary): void => { const bucket = buckets.get(key) if (bucket === undefined) buckets.set(key, [host]) else bucket.push(host) } for (const host of hosts) { if (groupBy === 'environment') { push(host.environment ?? '', host) } else if (host.tags.length === 0) { push('', host) } else { for (const tag of host.tags) push(tag, host) } } return [...buckets.entries()] .sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b))) .map(([key, group]) => ({ key, hosts: group })) } /** The hosts table plus its toolbar and dialogs. */ export function HostsTab({ api, onConnect }: HostsTabProps) { const [hosts, setHosts] = useState(null) const [error, setError] = useState(null) const [search, setSearch] = useState('') const [testingAlias, setTestingAlias] = useState(null) const [testResults, setTestResults] = useState>({}) const [importing, setImporting] = useState(false) const [notice, setNotice] = useState(null) /** Blocks the last import skipped, with the reason each one was left out. */ const [importSkips, setImportSkips] = useState([]) const [dialog, setDialog] = useState(null) const [groupBy, setGroupBy] = useState('none') const [collapsed, setCollapsed] = useState>({}) const [testingGroup, setTestingGroup] = useState(null) const seqRef = useRef(0) // Unmount guard for the async load below: the seq check only orders // overlapping loads, it does not stop a late resolution/rejection landing // after the tab unmounted — a setState there races the test-environment // teardown (window is not defined; observed as a main-CI flake). The // sibling tabs (terminal / transfer / tunnels) already guard with a // disposed flag. const mountedRef = useRef(true) useEffect(() => () => { mountedRef.current = false }, []) const load = useCallback(async (query?: string): Promise => { const seq = ++seqRef.current try { const list = await api.listHosts(query) if (!mountedRef.current || seq !== seqRef.current) return setHosts(list) setError(null) } catch (cause) { if (!mountedRef.current || seq !== seqRef.current) return setError(errorMessage(cause)) } }, [api]) useEffect(() => { void load() }, [load]) // Debounced search: every keystroke re-filters through the API. useEffect(() => { const timer = setTimeout(() => { const query = search.trim() void load(query === '' ? undefined : query) }, 300) return () => { clearTimeout(timer) } }, [search, load]) // Every async setState path guards with mountedRef, not just load(): a // promise settling after unmount would setState against the torn-down // environment (window is not defined, main-CI flake). const runTest = async (alias: string): Promise => { if (!mountedRef.current) return setTestingAlias(alias) try { const result = await api.testHost(alias) if (!mountedRef.current) return setTestResults(prev => ({ ...prev, [alias]: result })) } catch (cause) { if (!mountedRef.current) return setTestResults(prev => ({ ...prev, [alias]: { ok: false, error: errorMessage(cause) } })) } finally { if (mountedRef.current) setTestingAlias(null) } } const deleteHost = async (alias: string): Promise => { if (!window.confirm(tt('hosts.deleteConfirm', { alias }))) return try { await api.deleteHost(alias) if (!mountedRef.current) return void load() } catch (cause) { if (!mountedRef.current) return setError(errorMessage(cause)) } } // Group-header batch action (#379): test every host in the group. const testGroup = async (group: HostGroup): Promise => { if (!mountedRef.current) return setTestingGroup(group.key) try { await Promise.all(group.hosts.map(host => runTest(host.alias))) } finally { if (mountedRef.current) setTestingGroup(null) } } const importConfig = async (): Promise => { if (!mountedRef.current) return setImporting(true) try { const result = await api.importSshConfig() if (!mountedRef.current) return setNotice(tt('hosts.imported', { parsed: result.parsed, added: result.added, skipped: result.skipped })) setImportSkips(result.skippedBlocks) void load() } catch (cause) { if (!mountedRef.current) return setError(errorMessage(cause)) } finally { if (mountedRef.current) setImporting(false) } } const renderHostRow = (host: SshHostSummary): ReactNode => { const test = testResults[host.alias] return ( {host.alias} {host.host}:{host.port} {host.proxyCommand !== undefined && ( {tt('hosts.proxyBadge')} )} {host.user} {host.auth === 'key' ? tt('form.auth.key') : host.auth === 'password' ? tt('form.auth.password') : tt('form.auth.agent')} {host.environment ?? ''} {host.tags.join(', ')} {host.description ?? ''}
{testingAlias === host.alias &&
) } const renderHostTable = (rows: SshHostSummary[]): ReactNode => ( {rows.map(renderHostRow)}
{tt('hosts.col.alias')} {tt('hosts.col.host')} {tt('hosts.col.user')} {tt('hosts.col.auth')} {tt('hosts.col.environment')} {tt('hosts.col.tags')} {tt('hosts.col.description')} {tt('hosts.col.actions')}
) const groups = hosts === null ? [] : groupHosts(hosts, groupBy) return (
{ setSearch(event.target.value) }} />
{notice !== null &&
{notice}
} {notice !== null && importSkips.length > 0 && (
    {importSkips.slice(0, IMPORT_SKIP_LIMIT).map(block => (
  • {block.name} {tt(IMPORT_REASON_KEY[block.reason])}
  • ))} {importSkips.length > IMPORT_SKIP_LIMIT && (
  • {tt('import.more', { count: importSkips.length - IMPORT_SKIP_LIMIT })}
  • )}
)} {error !== null &&
{tt('common.error', { error })}
} {hosts === null && error === null &&
{tt('common.loading')}
} {hosts !== null && hosts.length === 0 &&
{tt('hosts.empty')}
} {hosts !== null && hosts.length > 0 && groupBy === 'none' && (
{renderHostTable(hosts)}
)} {hosts !== null && hosts.length > 0 && groupBy !== 'none' && (
{groups.map(group => { const isCollapsed = collapsed[group.key] === true const label = group.key === '' ? (groupBy === 'tags' ? tt('hosts.group.noTags') : tt('hosts.group.ungrouped')) : group.key return (
{!isCollapsed && renderHostTable(group.hosts)}
) })}
)} {dialog !== null && ( { setDialog(null) }} onSaved={() => { setDialog(null); void load() }} /> )}
) }