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
182 lines
8.4 KiB
TypeScript
182 lines
8.4 KiB
TypeScript
/**
|
|
* Host Chain Sub-Panel
|
|
* Panel for configuring SSH jump host chain
|
|
*/
|
|
import { ArrowDown,Plus,Search,X } from 'lucide-react';
|
|
import React, { useMemo, useState } from 'react';
|
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
|
import { Host } from '../../types';
|
|
import { DistroAvatar } from '../DistroAvatar';
|
|
import { AsidePanel, type AsidePanelLayout, type AsidePanelResizeProps } from '../ui/aside-panel';
|
|
import { Button } from '../ui/button';
|
|
import { Card } from '../ui/card';
|
|
import { FixedSizeVirtualList } from '../ui/FixedSizeVirtualList';
|
|
import { Input } from '../ui/input';
|
|
import { ScrollArea } from '../ui/scroll-area';
|
|
|
|
const CHAIN_HOST_ROW_HEIGHT = 52;
|
|
const CHAIN_HOST_VIEWPORT_HEIGHT = 256;
|
|
|
|
export interface ChainPanelProps {
|
|
formLabel: string;
|
|
formHostname: string;
|
|
form: Host;
|
|
chainedHosts: Host[];
|
|
availableHostsForChain: Host[];
|
|
onAddHost: (hostId: string) => void;
|
|
onRemoveHost: (index: number) => void;
|
|
onClearChain: () => void;
|
|
onBack: () => void;
|
|
onCancel: () => void;
|
|
layout?: AsidePanelLayout;
|
|
}
|
|
|
|
export type ChainPanelPropsWithResize = ChainPanelProps & AsidePanelResizeProps;
|
|
|
|
export const ChainPanel: React.FC<ChainPanelPropsWithResize> = ({
|
|
formLabel,
|
|
formHostname,
|
|
form,
|
|
chainedHosts,
|
|
availableHostsForChain,
|
|
onAddHost,
|
|
onRemoveHost,
|
|
onClearChain,
|
|
onBack,
|
|
onCancel,
|
|
layout = 'overlay',
|
|
resizable,
|
|
persistWidthStorageKey,
|
|
resizeAriaLabel,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const filteredHosts = useMemo(() => {
|
|
if (!searchQuery.trim()) return availableHostsForChain;
|
|
const q = searchQuery.toLowerCase();
|
|
return availableHostsForChain.filter(
|
|
(host) => host.label.toLowerCase().includes(q) || host.hostname.toLowerCase().includes(q)
|
|
);
|
|
}, [availableHostsForChain, searchQuery]);
|
|
return (
|
|
<AsidePanel
|
|
open={true}
|
|
onClose={onCancel}
|
|
title={t('hostDetails.chain.title')}
|
|
showBackButton={true}
|
|
onBack={onBack}
|
|
layout={layout}
|
|
resizable={resizable}
|
|
persistWidthStorageKey={persistWidthStorageKey}
|
|
resizeAriaLabel={resizeAriaLabel}
|
|
actions={
|
|
<Button size="sm" onClick={onBack}>
|
|
{t('common.save')}
|
|
</Button>
|
|
}
|
|
>
|
|
<ScrollArea className="flex-1">
|
|
<div className="p-4 space-y-4 w-0 min-w-full">
|
|
{/* Chain visualization */}
|
|
<div className="space-y-2">
|
|
{chainedHosts.map((host, index) => (
|
|
<React.Fragment key={host.id}>
|
|
{index > 0 && (
|
|
<div className="flex justify-center py-1">
|
|
<ArrowDown size={16} className="text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
|
|
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
|
|
<span className="text-sm font-medium flex-1 min-w-0 truncate">{host.label || host.hostname}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
|
onClick={() => onRemoveHost(index)}
|
|
>
|
|
<X size={14} />
|
|
</Button>
|
|
</div>
|
|
</React.Fragment>
|
|
))}
|
|
|
|
{chainedHosts.length > 0 && (
|
|
<div className="flex justify-center py-1">
|
|
<ArrowDown size={16} className="text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Target host (current) */}
|
|
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
|
|
<DistroAvatar
|
|
host={form}
|
|
fallback={formLabel?.slice(0, 2).toUpperCase() || formHostname?.slice(0, 2).toUpperCase() || "H"}
|
|
className="h-8 w-8"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{formLabel || formHostname || t('hostDetails.chain.target')}</div>
|
|
<div className="text-xs text-muted-foreground">{t('hostDetails.chain.target')}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Available hosts to add */}
|
|
{availableHostsForChain.length > 0 && (
|
|
<Card className="p-3 bg-card border-border/80">
|
|
<p className="text-xs font-semibold text-muted-foreground mb-2">{t('hostDetails.chain.availableHosts')}</p>
|
|
<div className="relative mb-2">
|
|
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={t('common.searchPlaceholder')}
|
|
className="h-8 pl-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div
|
|
className="max-h-64"
|
|
style={{
|
|
height: Math.min(
|
|
filteredHosts.length * CHAIN_HOST_ROW_HEIGHT,
|
|
CHAIN_HOST_VIEWPORT_HEIGHT,
|
|
),
|
|
}}
|
|
>
|
|
<FixedSizeVirtualList
|
|
items={filteredHosts}
|
|
itemHeight={CHAIN_HOST_ROW_HEIGHT}
|
|
className="h-full"
|
|
getItemKey={(host) => host.id}
|
|
renderItem={(host, index) => (
|
|
<button
|
|
key={host.id}
|
|
className={`w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left overflow-hidden${index > 0 ? ' mt-1' : ''}`}
|
|
onClick={() => onAddHost(host.id)}
|
|
>
|
|
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{host.label}</div>
|
|
<div className="text-xs text-muted-foreground truncate">{host.hostname}</div>
|
|
</div>
|
|
<Plus size={14} className="text-muted-foreground" />
|
|
</button>
|
|
)}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{chainedHosts.length > 0 && (
|
|
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearChain}>
|
|
{t('hostDetails.chain.clear')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</AsidePanel>
|
|
);
|
|
};
|
|
|
|
export default ChainPanel;
|