[Init] Initial commit - NetMesh terminal manager
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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
/**
* Generate Key Panel - Standard SSH Key generation form
*/
import { Eye, EyeOff } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { KeyType, SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
interface GenerateStandardPanelProps {
draftKey: Partial<SSHKey>;
setDraftKey: (key: Partial<SSHKey>) => void;
showPassphrase: boolean;
setShowPassphrase: (show: boolean) => void;
isGenerating: boolean;
onGenerate: () => void;
}
export const GenerateStandardPanel: React.FC<GenerateStandardPanelProps> = ({
draftKey,
setDraftKey,
showPassphrase,
setShowPassphrase,
isGenerating,
onGenerate,
}) => {
const { t } = useI18n();
return (
<>
<div className="space-y-2">
<Label>{t('keychain.field.label')}</Label>
<Input
value={draftKey.label || ''}
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
placeholder={t('keychain.generate.labelPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('keychain.generate.keyType')}</Label>
<div className="flex gap-2">
{(['ED25519', 'ECDSA', 'RSA'] as KeyType[]).map((t) => (
<Button
key={t}
variant={draftKey.type === t ? 'secondary' : 'ghost'}
className={cn(
"flex-1 h-10",
draftKey.type === t && "bg-primary/15 text-primary"
)}
onClick={() => {
// Set default keySize based on type
const defaultSize = t === 'ED25519' ? undefined : (t === 'RSA' ? 4096 : 256);
setDraftKey({ ...draftKey, type: t, keySize: defaultSize });
}}
>
{t}
</Button>
))}
</div>
</div>
{/* Key Size selector - only for RSA and ECDSA */}
{(draftKey.type === 'RSA' || draftKey.type === 'ECDSA') && (
<div className="space-y-2">
<Label>{t('keychain.generate.keySize')}</Label>
<div className="flex gap-2">
{(draftKey.type === 'RSA'
? [4096, 2048, 1024]
: [256, 384, 521]
).map((size) => (
<Button
key={size}
variant={draftKey.keySize === size ? 'secondary' : 'ghost'}
className={cn(
"flex-1 h-10",
draftKey.keySize === size && "bg-primary/15 text-primary"
)}
onClick={() => setDraftKey({ ...draftKey, keySize: size })}
>
{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}
</Button>
))}
</div>
</div>
)}
<div className="space-y-2">
<Label>{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input
type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''}
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
placeholder={t('keychain.generate.passphrasePlaceholder')}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="savePassphrase"
checked={draftKey.savePassphrase || false}
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="savePassphrase" className="text-sm font-normal cursor-pointer">
{t('keychain.generate.savePassphrase')}
</Label>
</div>
<Button
className="w-full h-11"
onClick={onGenerate}
disabled={isGenerating || !draftKey.label?.trim()}
>
{isGenerating ? (
<div className="h-4 w-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
) : (
t('keychain.generate.generateSave')
)}
</Button>
</>
);
};

View File

@@ -0,0 +1,87 @@
/**
* Identity Card component for displaying saved identities
*/
import { Pencil,User } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Identity } from '../../types';
import { Button } from '../ui/button';
import { VaultEntityIcon, vaultIdentityIconClass } from '../vault/VaultEntityIcon';
interface IdentityCardProps {
identity: Identity;
viewMode: 'grid' | 'list';
isSelected: boolean;
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
onClick: () => void;
}
export const IdentityCard: React.FC<IdentityCardProps> = ({
identity,
viewMode,
isSelected,
reorderProps,
onClick,
}) => {
const { t } = useI18n();
const hasPassword = !!identity.password;
const hasKey = !!identity.keyId;
const keyKind = identity.authMethod === 'certificate' ? 'certificate' : 'key';
const summary = hasPassword && hasKey
? (keyKind === 'certificate'
? t('keychain.identity.summary.passwordAndCertificate')
: t('keychain.identity.summary.passwordAndKey'))
: hasKey
? (keyKind === 'certificate'
? t('keychain.identity.summary.certificate')
: t('keychain.identity.summary.key'))
: hasPassword
? t('keychain.identity.summary.password')
: t('keychain.identity.summary.none');
return (
<div
{...reorderProps}
className={cn(
reorderProps && "vault-drop-indicator-row",
"group cursor-pointer min-w-0 w-full max-w-full",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
isSelected && "ring-2 ring-primary",
reorderProps?.className,
)}
onClick={onClick}
>
<div className="flex items-center gap-3 h-full min-w-0">
<VaultEntityIcon
className={vaultIdentityIconClass}
icon={<User size={18} />}
/>
<div className="min-w-0 flex-1 basis-0 overflow-hidden">
<div className="block max-w-full truncate text-sm font-semibold">{identity.label || 'Add a label...'}</div>
<div className="block max-w-full truncate text-[11px] font-mono text-muted-foreground">
{summary}
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<Pencil size={14} />
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,267 @@
/**
* Identity Panel - Create/Edit identity
*/
import { Eye,EyeOff,Key,Plus,Shield,User,X } from 'lucide-react';
import React,{ useMemo,useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Identity,SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Combobox } from '../ui/combobox';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Popover,PopoverContent,PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface IdentityPanelProps {
draftIdentity: Partial<Identity>;
setDraftIdentity: (identity: Partial<Identity>) => void;
keys: SSHKey[];
showPassphrase: boolean;
setShowPassphrase: (show: boolean) => void;
isNew: boolean;
onSave: () => void;
}
export const IdentityPanel: React.FC<IdentityPanelProps> = ({
draftIdentity,
setDraftIdentity,
keys,
showPassphrase,
setShowPassphrase,
isNew,
onSave,
}) => {
const { t } = useI18n();
type CredentialType = 'key' | 'certificate' | null;
const [credentialPopoverOpen, setCredentialPopoverOpen] = useState(false);
const [selectedCredentialType, setSelectedCredentialType] = useState<CredentialType>(null);
const selectedKey = useMemo(() => {
if (!draftIdentity.keyId) return undefined;
return keys.find(k => k.id === draftIdentity.keyId);
}, [draftIdentity.keyId, keys]);
const keysByCategory = useMemo(() => {
return {
key: keys.filter(k => k.category === 'key' && !k.certificate),
certificate: keys.filter(k => k.category === 'certificate' || !!k.certificate),
};
}, [keys]);
const clearSelectedKey = () => {
setDraftIdentity({
...draftIdentity,
keyId: undefined,
authMethod: 'password',
});
setSelectedCredentialType(null);
setCredentialPopoverOpen(false);
};
const setSelectedKeyId = (keyId: string, kind: Exclude<CredentialType, null>) => {
setDraftIdentity({
...draftIdentity,
keyId: keyId || undefined,
authMethod: kind === 'certificate' ? 'certificate' : 'key',
});
setSelectedCredentialType(null);
setCredentialPopoverOpen(false);
};
return (
<>
<div className="flex items-center gap-3 mb-4">
<div className="h-10 w-10 rounded-lg bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950 flex items-center justify-center">
<User size={20} />
</div>
<Input
value={draftIdentity.label || ''}
onChange={e => setDraftIdentity({ ...draftIdentity, label: e.target.value })}
placeholder={t('keychain.field.label')}
className="flex-1"
/>
</div>
<div className="space-y-2">
<Label>{t('keychain.identity.usernameRequired')}</Label>
<div className="relative">
<User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={draftIdentity.username || ''}
onChange={e => setDraftIdentity({ ...draftIdentity, username: e.target.value })}
placeholder={t('terminal.auth.username')}
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('terminal.auth.passwordLabel')}</Label>
<div className="relative">
<Input
type={showPassphrase ? 'text' : 'password'}
value={draftIdentity.password || ''}
onChange={e => setDraftIdentity({ ...draftIdentity, password: e.target.value })}
placeholder={t('terminal.auth.password.placeholder')}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
</div>
{/* Selected credential display */}
{draftIdentity.keyId && (
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
{draftIdentity.authMethod === 'certificate' ? (
<Shield size={14} className="text-primary" />
) : (
<Key size={14} className="text-primary" />
)}
<span className="text-sm flex-1 truncate">
{selectedKey?.label || t('hostDetails.credential.missing')}
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={clearSelectedKey}
>
<X size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.clear')}</TooltipContent>
</Tooltip>
</div>
)}
{/* Credential type selection with inline popover */}
{!draftIdentity.keyId && !selectedCredentialType && (
<Popover open={credentialPopoverOpen} onOpenChange={setCredentialPopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
>
<Plus size={12} />
<span>{t('hostDetails.credential.keyCertificate')}</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-1" align="start" sideOffset={4}>
<div className="space-y-0.5">
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
onClick={() => {
setSelectedCredentialType('key');
setCredentialPopoverOpen(false);
}}
>
<Key size={16} className="text-muted-foreground" />
<span className="text-sm font-medium">{t('hostDetails.credential.key')}</span>
</button>
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
onClick={() => {
setSelectedCredentialType('certificate');
setCredentialPopoverOpen(false);
}}
>
<Shield size={16} className="text-muted-foreground" />
<span className="text-sm font-medium">
{t('hostDetails.credential.certificate')}
</span>
</button>
</div>
</PopoverContent>
</Popover>
)}
{/* Key selection combobox - appears after selecting "Key" type */}
{selectedCredentialType === 'key' && !draftIdentity.keyId && (
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.key.map(k => ({
value: k.id,
label: k.label,
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ''}`,
icon: <Key size={14} className="text-muted-foreground" />,
}))}
value={draftIdentity.keyId}
onValueChange={(val) => setSelectedKeyId(val, 'key')}
placeholder={t('hostDetails.keys.search')}
emptyText={t('hostDetails.keys.empty')}
icon={<Key size={14} className="text-muted-foreground" />}
className="flex-1"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.cancel')}</TooltipContent>
</Tooltip>
</div>
)}
{/* Certificate selection combobox */}
{selectedCredentialType === 'certificate' && !draftIdentity.keyId && (
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.certificate.map(k => ({
value: k.id,
label: k.label,
icon: <Shield size={14} className="text-muted-foreground" />,
}))}
value={draftIdentity.keyId}
onValueChange={(val) => setSelectedKeyId(val, 'certificate')}
placeholder={t('hostDetails.certs.search')}
emptyText={t('hostDetails.certs.empty')}
icon={<Shield size={14} className="text-muted-foreground" />}
className="flex-1"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.cancel')}</TooltipContent>
</Tooltip>
</div>
)}
<Button
className="w-full h-11"
onClick={onSave}
disabled={!draftIdentity.label?.trim() || !draftIdentity.username?.trim()}
>
{isNew ? t('keychain.identity.save') : t('keychain.identity.update')}
</Button>
</>
);
};

View File

@@ -0,0 +1,201 @@
/**
* Import Key Panel - Import existing SSH key
*/
import { Eye, EyeOff, Upload } from 'lucide-react';
import React,{ useCallback,useRef } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { detectKeyType } from './utils';
interface ImportKeyPanelProps {
draftKey: Partial<SSHKey>;
setDraftKey: (key: Partial<SSHKey>) => void;
showPassphrase: boolean;
setShowPassphrase: (show: boolean) => void;
onImport: () => void;
}
export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
draftKey,
setDraftKey,
showPassphrase,
setShowPassphrase,
onImport,
}) => {
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileImport = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
const detectedType = detectKeyType(content);
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, '');
setDraftKey({
...draftKey,
privateKey: content,
label: draftKey.label || label,
type: detectedType,
});
}
};
reader.readAsText(file);
event.target.value = '';
}, [draftKey, setDraftKey]);
const handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const file = event.dataTransfer.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
const detectedType = detectKeyType(content);
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, '');
setDraftKey({
...draftKey,
privateKey: content,
label: draftKey.label || label,
type: detectedType,
});
}
};
reader.readAsText(file);
}, [draftKey, setDraftKey]);
const handleDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
}, []);
return (
<>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileImport}
/>
<div className="space-y-2">
<Label>{t('keychain.field.label')}</Label>
<Input
value={draftKey.label || ''}
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
placeholder={t('keychain.field.labelPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('keychain.field.privateKeyRequired')}</Label>
<Textarea
value={draftKey.privateKey || ''}
onChange={e => setDraftKey({ ...draftKey, privateKey: e.target.value })}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[120px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label>{t('keychain.field.publicKey')}</Label>
<Textarea
value={draftKey.publicKey || ''}
onChange={e => setDraftKey({ ...draftKey, publicKey: e.target.value })}
placeholder="ssh-ed25519 AAAAC3... user@host"
className="min-h-[80px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
{t('terminal.auth.certificate')}
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{t('common.optional')}
</span>
</Label>
<Textarea
value={draftKey.certificate || ''}
onChange={e => setDraftKey({ ...draftKey, certificate: e.target.value })}
placeholder={t('keychain.field.certificatePlaceholder')}
className="min-h-[80px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label>{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input
type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''}
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
placeholder={t('keychain.generate.passphrasePlaceholder')}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="savePassphraseImport"
checked={draftKey.savePassphrase || false}
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="savePassphraseImport" className="text-sm font-normal cursor-pointer">
{t('keychain.generate.savePassphrase')}
</Label>
</div>
<div
className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60 transition-colors hover:border-primary/50"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Upload size={16} />
<span className="text-sm">{t('keychain.import.dropHint')}</span>
</div>
<Button
variant="secondary"
className="w-full"
onClick={() => fileInputRef.current?.click()}
>
{t('keychain.import.importFromFile')}
</Button>
</div>
<Button
className="w-full h-11"
onClick={onImport}
disabled={!draftKey.label?.trim() || !draftKey.privateKey?.trim()}
>
{t('keychain.import.saveKey')}
</Button>
</>
);
};

View File

@@ -0,0 +1,117 @@
/**
* Key Card component for displaying SSH keys in grid/list view
*/
import { Copy,ExternalLink,Pencil,Trash2 } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import {
VaultEntityIcon,
vaultCertificateIconClass,
vaultKeyIconClass,
} from '../vault/VaultEntityIcon';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '../ui/context-menu';
import { getKeyIcon,getKeyTypeDisplay } from './utils';
interface KeyCardProps {
keyItem: SSHKey;
viewMode: 'grid' | 'list';
isSelected: boolean;
isMac: boolean;
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
onClick: () => void;
onEdit: () => void;
onExport: () => void;
onCopyPublicKey: () => void;
onDelete: () => void;
}
export const KeyCard: React.FC<KeyCardProps> = ({
keyItem,
viewMode,
isSelected,
isMac,
reorderProps,
onClick,
onEdit,
onExport,
onCopyPublicKey,
onDelete,
}) => {
const { t } = useI18n();
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
{...reorderProps}
className={cn(
reorderProps && "vault-drop-indicator-row",
"group cursor-pointer min-w-0 w-full max-w-full",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
isSelected && "ring-2 ring-primary",
reorderProps?.className,
)}
onClick={onClick}
>
<div className="flex items-center gap-3 h-full min-w-0">
<VaultEntityIcon
className={keyItem.certificate
? vaultCertificateIconClass
: vaultKeyIconClass}
icon={getKeyIcon(keyItem)}
/>
<div className="min-w-0 flex-1 basis-0 overflow-hidden">
<div className="block max-w-full truncate text-sm font-semibold">{keyItem.label}</div>
<div className="block max-w-full truncate text-[11px] font-mono text-muted-foreground">
Type {getKeyTypeDisplay(keyItem, isMac)}
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil size={14} />
</Button>
</div>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onCopyPublicKey} disabled={!keyItem.publicKey}>
<Copy size={14} className="mr-2" />
{t("action.copyPublicKey")}
</ContextMenuItem>
<ContextMenuItem onClick={onExport}>
<ExternalLink size={14} className="mr-2" />
{t("action.keyExport")}
</ContextMenuItem>
<ContextMenuItem onClick={onEdit}>
<Pencil size={14} className="mr-2" />
{t("action.edit")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Trash2 size={14} className="mr-2" />
{t("action.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};

View File

@@ -0,0 +1,94 @@
/**
* View Key Panel - Display SSH key details
*/
import { Check, Copy, Info } from 'lucide-react';
import React, { useCallback, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Label } from '../ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { copyToClipboard } from './utils';
interface ViewKeyPanelProps {
keyItem: SSHKey;
onExport: () => void;
}
export const ViewKeyPanel: React.FC<ViewKeyPanelProps> = ({
keyItem,
onExport,
}) => {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const handleCopyPublicKey = useCallback(async () => {
const ok = await copyToClipboard(keyItem.publicKey || '');
if (!ok) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [keyItem.publicKey]);
return (
<>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.field.label')}</Label>
<p className="text-sm">{keyItem.label}</p>
</div>
{keyItem.publicKey && (
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.field.publicKey')}</Label>
<div className="flex rounded-lg border border-border/80 bg-card overflow-hidden">
<div className="flex-1 min-w-0 p-3 font-mono text-xs break-all max-h-32 overflow-y-auto">
{keyItem.publicKey}
</div>
<div className="shrink-0 flex flex-col border-l border-border/60 p-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => void handleCopyPublicKey()}
aria-label={
copied
? t('cloudSync.githubFlow.copied')
: t('action.copyPublicKey')
}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{copied
? t('cloudSync.githubFlow.copied')
: t('action.copyPublicKey')}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
)}
<div className="space-y-1">
<Label className="text-muted-foreground">{t('field.type')}</Label>
<p className="text-sm">{keyItem.type}</p>
</div>
{/* Key Export section */}
<div className="pt-4 mt-4 border-t border-border/60">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-medium">{t('keychain.export.title')}</span>
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
<Info size={10} className="text-muted-foreground" />
</div>
</div>
<Button className="w-full h-11" onClick={onExport}>
{t('keychain.export.exportToHost')}
</Button>
</div>
</>
);
};

View File

@@ -0,0 +1,20 @@
/**
* Keychain Components - Index
*
* Re-exports all keychain-related components and utilities
*/
// Utilities and types
export {
isMacOS,shouldShowIdentitySection,shouldShowKeySection,shouldShowSearchNoResults,type PanelMode
} from './utils';
// Card components
export { IdentityCard } from './IdentityCard';
export { KeyCard } from './KeyCard';
// Panel components
export { GenerateStandardPanel } from './GenerateStandardPanel';
export { IdentityPanel } from './IdentityPanel';
export { ImportKeyPanel } from './ImportKeyPanel';
export { ViewKeyPanel } from './ViewKeyPanel';

View File

@@ -0,0 +1,105 @@
/**
* Keychain utility functions
*/
import { BadgeCheck, Key } from 'lucide-react';
import React from 'react';
import { logger } from '../../lib/logger';
import { KeyType, SSHKey } from '../../types';
/**
* Get icon element for key source
*/
export const getKeyIcon = (key: SSHKey): React.ReactElement => {
if (key.certificate) return React.createElement(BadgeCheck, { size: 16 });
return React.createElement(Key, { size: 16 });
};
/**
* Get display text for key type
*/
export const getKeyTypeDisplay = (key: SSHKey, isMac: boolean): string => {
void isMac;
return key.type;
};
/**
* Detect key type from private key content
*/
export const detectKeyType = (privateKey: string): KeyType => {
const pk = privateKey.toLowerCase();
if (pk.includes('rsa')) return 'RSA';
if (pk.includes('ecdsa') || pk.includes('ec ')) return 'ECDSA';
return 'ED25519';
};
/**
* Copy text to clipboard
*/
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
logger.error('Failed to copy to clipboard:', err);
return false;
}
};
/**
* Check if running on macOS
*/
export const isMacOS = (): boolean => {
return navigator.platform.toLowerCase().includes('mac') ||
navigator.userAgent.toLowerCase().includes('mac');
};
// Panel modes type
export type PanelMode =
| { type: 'closed' }
| { type: 'view'; key: SSHKey }
| { type: 'edit'; key: SSHKey }
| { type: 'generate'; keyType: 'standard' }
| { type: 'import' }
| { type: 'identity'; identity?: import('../../types').Identity }
| { type: 'export'; key: SSHKey };
interface IdentitySectionVisibilityOptions {
identityCount: number;
filteredIdentityCount: number;
filteredKeyCount: number;
search: string;
}
/** Show identities whenever any exist; while searching, keep the section if it matches or nothing matches. */
export const shouldShowIdentitySection = ({
identityCount,
filteredIdentityCount,
filteredKeyCount,
search,
}: IdentitySectionVisibilityOptions): boolean => {
if (identityCount === 0) return false;
if (!search.trim()) return true;
return filteredIdentityCount > 0 || filteredKeyCount === 0;
};
/**
* Show keys when any match (or exist while browsing). Hide the empty-key CTA when
* identities alone already fill the page - including identity-only vaults.
*/
export const shouldShowKeySection = ({
identityCount,
filteredKeyCount,
}: Pick<
IdentitySectionVisibilityOptions,
'identityCount' | 'filteredKeyCount' | 'search'
>): boolean => {
return filteredKeyCount > 0 || identityCount === 0;
};
export const shouldShowSearchNoResults = (
search: string,
filteredItemCount: number,
totalItemCount: number,
): boolean => Boolean(search.trim()) && totalItemCount > 0 && filteredItemCount === 0;