Files
NetMesh/components/sftp/SftpHostPicker.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

362 lines
15 KiB
TypeScript

/**
* SFTP Host Picker Dialog
*/
import { Monitor, Search } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
sftpHostEndpointsEqual,
sftpSourceSessionIdForHost,
type SftpConnectedHostEntry,
} from '../../domain/sftpConnectedHosts';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { getQuickSwitcherRowStateClass, shouldUseQuickSwitcherPointerNavigation } from '../QuickSwitcher';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { Input } from '../ui/input';
import {
VariableSizeVirtualList,
type VariableSizeVirtualListHandle,
} from '../ui/VariableSizeVirtualList';
import { clampListIndex, stepListIndex } from '../ui/virtualListMath';
const SFTP_PICKER_ROW_HEIGHT = 44;
const SFTP_PICKER_HEADER_HEIGHT = 32;
const SFTP_PICKER_EMPTY_HEIGHT = 56;
interface SftpHostPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
hosts: Host[];
connectedHosts?: SftpConnectedHostEntry[];
side: 'left' | 'right';
hostSearch: string;
onHostSearchChange: (search: string) => void;
onSelectLocal: () => void;
onSelectHost: (host: Host, options?: { sourceSessionId?: string }) => void;
}
const StatusDot: React.FC = () => (
<span className="h-1.5 w-1.5 rounded-full shrink-0 bg-emerald-500" aria-hidden />
);
function formatHostMeta(host: Host): string {
const endpoint = host.username ? `${host.username}@${host.hostname}` : host.hostname;
return host.group ? `${endpoint} · ${host.group}` : endpoint;
}
type PickerItem =
| { type: 'local'; id: string }
| { type: 'connected'; id: string; entry: SftpConnectedHostEntry }
| { type: 'host'; id: string; host: Host };
type VisualRow =
| { kind: 'header'; key: string; label: string }
| { kind: 'item'; key: string; item: PickerItem; itemIndex: number }
| { kind: 'empty'; key: string; message: string };
const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
open,
onOpenChange,
hosts,
connectedHosts = [],
side,
hostSearch,
onHostSearchChange,
onSelectLocal,
onSelectHost,
}) => {
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<VariableSizeVirtualListHandle>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(true);
const isKeyboardNavigatingRef = useRef(true);
const term = hostSearch.trim().toLowerCase();
const filteredConnectedHosts = useMemo(() => {
return connectedHosts.filter(({ host }) =>
!term ||
host.label.toLowerCase().includes(term) ||
host.hostname.toLowerCase().includes(term) ||
host.username.toLowerCase().includes(term),
);
}, [connectedHosts, term]);
const connectedByHostId = useMemo(() => {
const map = new Map<string, SftpConnectedHostEntry>();
for (const entry of filteredConnectedHosts) {
map.set(entry.host.id, entry);
}
return map;
}, [filteredConnectedHosts]);
const filteredHosts = useMemo(() => {
return hosts.filter((h) => {
// SFTP is an SSH-specific host capability. Plugin protocols may
// provide arbitrary transports and cannot be treated as SSH.
if (h.protocol === "serial" || isPluginHostProtocol(h.protocol)) return false;
// Hide a saved host only when Connected already shows the same endpoint.
// If the vault host was edited after connect, keep both: Live (old) + Saved (new).
const connected = connectedByHostId.get(h.id);
if (connected && sftpHostEndpointsEqual(h, connected.host)) return false;
return !term
|| h.label.toLowerCase().includes(term)
|| h.hostname.toLowerCase().includes(term);
}).sort((a, b) => a.label.localeCompare(b.label));
}, [hosts, term, connectedByHostId]);
const sideLabel = side === 'left' ? t('common.left') : t('common.right');
const { items, visualRows, itemIndexToVisualIndex } = useMemo(() => {
const nextItems: PickerItem[] = [];
const nextVisual: VisualRow[] = [];
const nextMap = new Map<number, number>();
const pushHeader = (key: string, label: string) => {
nextVisual.push({ kind: 'header', key, label });
};
const pushItem = (item: PickerItem) => {
const itemIndex = nextItems.length;
nextItems.push(item);
nextMap.set(itemIndex, nextVisual.length);
nextVisual.push({ kind: 'item', key: item.id, item, itemIndex });
};
pushHeader('header:local', t('sftp.picker.local.badge'));
pushItem({ type: 'local', id: 'local' });
if (filteredConnectedHosts.length > 0) {
pushHeader('header:connected', t('sftp.picker.connected.section'));
for (const entry of filteredConnectedHosts) {
pushItem({
type: 'connected',
id: `connected:${entry.sessionId}`,
entry,
});
}
}
// Only show the Hosts section when there are saved hosts to list, or when
// nothing matched at all (no connected + no saved). Avoid a dangling
// "Hosts" header after connected-only results hide the saved inventory.
if (filteredHosts.length > 0) {
pushHeader('header:hosts', t('vault.nav.hosts'));
for (const host of filteredHosts) {
pushItem({ type: 'host', id: host.id, host });
}
} else if (filteredConnectedHosts.length === 0) {
pushHeader('header:hosts', t('vault.nav.hosts'));
nextVisual.push({
kind: 'empty',
key: 'empty:hosts',
message: t('sftp.picker.noMatch'),
});
}
return {
items: nextItems,
visualRows: nextVisual,
itemIndexToVisualIndex: nextMap,
};
}, [filteredConnectedHosts, filteredHosts, t]);
useEffect(() => {
if (!open) return;
setSelectedIndex(0);
isKeyboardNavigatingRef.current = true;
setIsKeyboardNavigating(true);
const focusTimer = setTimeout(() => inputRef.current?.focus(), 50);
return () => clearTimeout(focusTimer);
}, [open]);
useEffect(() => {
if (!open) return;
setSelectedIndex(0);
isKeyboardNavigatingRef.current = true;
setIsKeyboardNavigating(true);
}, [hostSearch, open]);
useEffect(() => {
if (!open) return;
setSelectedIndex((prev) => clampListIndex(prev, items.length));
}, [items.length, open]);
useEffect(() => {
if (!open) return;
const visualIndex = itemIndexToVisualIndex.get(selectedIndex);
if (visualIndex === undefined) return;
listRef.current?.scrollToIndex(visualIndex, 'auto');
}, [itemIndexToVisualIndex, open, selectedIndex]);
const handleSelect = useCallback((item: PickerItem) => {
if (item.type === 'local') {
onSelectLocal();
} else if (item.type === 'connected') {
// Sudo SFTP cannot reuse the terminal shell; omit the hint so connect
// UI (reusedConnection / spinner) matches the dedicated open path.
const sourceSessionId = sftpSourceSessionIdForHost(
item.entry.host,
item.entry.sessionId,
);
onSelectHost(
item.entry.host,
sourceSessionId ? { sourceSessionId } : undefined,
);
} else {
onSelectHost(item.host);
}
onOpenChange(false);
}, [onOpenChange, onSelectHost, onSelectLocal]);
// Match Quick Switcher: pointer movement only leaves keyboard-nav mode.
// It must not rewrite the keyboard-selected index until the user clicks.
const handlePointerHover = useCallback((movementX: number, movementY: number) => {
if (!shouldUseQuickSwitcherPointerNavigation(movementX, movementY)) return;
if (!isKeyboardNavigatingRef.current) return;
isKeyboardNavigatingRef.current = false;
setIsKeyboardNavigating(false);
}, []);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
isKeyboardNavigatingRef.current = true;
setIsKeyboardNavigating(true);
setSelectedIndex((prev) => stepListIndex(prev, items.length, 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
isKeyboardNavigatingRef.current = true;
setIsKeyboardNavigating(true);
setSelectedIndex((prev) => stepListIndex(prev, items.length, -1));
} else if (e.key === 'Enter' && items.length > 0) {
e.preventDefault();
const item = items[clampListIndex(selectedIndex, items.length)];
if (!item) return;
handleSelect(item);
}
};
const getRowHeight = useCallback((row: VisualRow) => {
if (row.kind === 'header') return SFTP_PICKER_HEADER_HEIGHT;
if (row.kind === 'empty') return SFTP_PICKER_EMPTY_HEIGHT;
return SFTP_PICKER_ROW_HEIGHT;
}, []);
// Cap at 360px for large inventories, but shrink to content for short lists
// (Local-only / few hosts) so the dialog does not leave a large blank region.
const listViewportHeight = useMemo(() => {
let total = 0;
for (const row of visualRows) total += getRowHeight(row);
return Math.min(360, Math.max(total, 1));
}, [getRowHeight, visualRows]);
const renderRow = useCallback((row: VisualRow) => {
if (row.kind === 'header') {
return (
<div className="flex h-full items-end px-4 pb-1.5">
<span className="text-xs font-medium text-muted-foreground">{row.label}</span>
</div>
);
}
if (row.kind === 'empty') {
return (
<div className="px-4 py-6 text-xs text-muted-foreground text-center">
{row.message}
</div>
);
}
const { item, itemIndex } = row;
const isSelected = selectedIndex === itemIndex;
if (item.type === 'local') {
return (
<div
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`}
onClick={() => handleSelect(item)}
onMouseMove={(event) => handlePointerHover(event.movementX, event.movementY)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<Monitor size={16} />
</div>
<span className="text-sm font-medium truncate">{t('sftp.picker.local.title')}</span>
</div>
<div className="ml-3 shrink-0 text-[11px] text-muted-foreground truncate max-w-[12rem]">
{t('sftp.picker.local.desc')}
</div>
</div>
);
}
const host = item.type === 'connected' ? item.entry.host : item.host;
const showStatus = item.type === 'connected';
return (
<div
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`}
onClick={() => handleSelect(item)}
onMouseMove={(event) => handlePointerHover(event.movementX, event.movementY)}
>
<div className="flex items-center gap-3 min-w-0">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} size="sm" />
<div className="flex min-w-0 items-center gap-1.5">
{showStatus ? <StatusDot /> : null}
<span className="text-sm font-medium truncate">{host.label}</span>
</div>
</div>
<div className="ml-3 shrink-0 text-[11px] text-muted-foreground truncate max-w-[12rem]">
{formatHostMeta(host)}
</div>
</div>
);
}, [handlePointerHover, handleSelect, isKeyboardNavigating, selectedIndex, t]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 overflow-hidden gap-0">
<DialogHeader className="sr-only">
<DialogTitle>{t('sftp.picker.title')}</DialogTitle>
<DialogDescription>
{t('sftp.picker.desc', { side: side === 'left' ? t('common.left') : t('common.right') })}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-3 px-4 py-3 pr-12 border-b border-border">
<Search size={16} className="text-muted-foreground" />
<Input
ref={inputRef}
value={hostSearch}
onChange={e => onHostSearchChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('sftp.picker.searchPlaceholder')}
className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm"
/>
<span className="ml-auto mr-1 text-[11px] text-muted-foreground bg-muted px-2 py-0.5 rounded whitespace-nowrap">
{sideLabel}
</span>
</div>
<div
className="max-h-[360px]"
style={{ height: listViewportHeight }}
data-host-picker-virtual="sftp"
>
<VariableSizeVirtualList<VisualRow>
ref={listRef}
items={visualRows}
getItemHeight={getRowHeight}
className="h-full"
overscan={8}
getItemKey={(row) => row.key}
renderItem={renderRow}
/>
</div>
</DialogContent>
</Dialog>
);
};
export const SftpHostPicker = memo(SftpHostPickerInner);
SftpHostPicker.displayName = 'SftpHostPicker';