|
| 1 | +import { useState, useEffect } from 'react' |
| 2 | +import { FileCode, Check, ChevronDown, ChevronRight, Server, AlertCircle, FolderOpen, Loader2 } from 'lucide-react' |
| 3 | +import { parseSshConfig, SshConfigHost } from '../../lib/sshConfigParser' |
| 4 | +import { cn } from '../../lib/utils' |
| 5 | + |
| 6 | +interface Props { |
| 7 | + onImport: (hosts: SshConfigHost[]) => Promise<void> |
| 8 | + onCancel: () => void |
| 9 | +} |
| 10 | + |
| 11 | +export function SshConfigImportDialog({ onImport, onCancel }: Props): JSX.Element { |
| 12 | + const [hosts, setHosts] = useState<SshConfigHost[]>([]) |
| 13 | + const [selected, setSelected] = useState<Set<string>>(new Set()) |
| 14 | + const [loading, setLoading] = useState(true) |
| 15 | + const [error, setError] = useState('') |
| 16 | + const [importing, setImporting] = useState(false) |
| 17 | + const [expanded, setExpanded] = useState<Set<string>>(new Set()) |
| 18 | + |
| 19 | + const loadDefault = async () => { |
| 20 | + setLoading(true); setError('') |
| 21 | + try { |
| 22 | + const content = await window.api.file.readSshConfig(false) |
| 23 | + if (!content) { setError('~/.ssh/config not found'); setLoading(false); return } |
| 24 | + const parsed = parseSshConfig(content) |
| 25 | + if (parsed.length === 0) { setError('No hosts found in config'); setLoading(false); return } |
| 26 | + setHosts(parsed) |
| 27 | + setSelected(new Set(parsed.map((h) => h.name))) |
| 28 | + } catch { |
| 29 | + setError('Failed to read ~/.ssh/config') |
| 30 | + } |
| 31 | + setLoading(false) |
| 32 | + } |
| 33 | + |
| 34 | + const loadCustom = async () => { |
| 35 | + setLoading(true); setError('') |
| 36 | + try { |
| 37 | + const content = await window.api.file.readSshConfig(true) |
| 38 | + if (!content) { setLoading(false); return } |
| 39 | + const parsed = parseSshConfig(content) |
| 40 | + if (parsed.length === 0) { setError('No hosts found in selected file'); setLoading(false); return } |
| 41 | + setHosts(parsed) |
| 42 | + setSelected(new Set(parsed.map((h) => h.name))) |
| 43 | + } catch { |
| 44 | + setError('Failed to read selected file') |
| 45 | + } |
| 46 | + setLoading(false) |
| 47 | + } |
| 48 | + |
| 49 | + useEffect(() => { loadDefault() }, []) |
| 50 | + |
| 51 | + const toggleHost = (name: string) => { |
| 52 | + setSelected((prev) => { |
| 53 | + const next = new Set(prev) |
| 54 | + next.has(name) ? next.delete(name) : next.add(name) |
| 55 | + return next |
| 56 | + }) |
| 57 | + } |
| 58 | + |
| 59 | + const toggleAll = () => { |
| 60 | + setSelected(selected.size === hosts.length ? new Set() : new Set(hosts.map((h) => h.name))) |
| 61 | + } |
| 62 | + |
| 63 | + const toggleExpand = (name: string) => { |
| 64 | + setExpanded((prev) => { |
| 65 | + const next = new Set(prev) |
| 66 | + next.has(name) ? next.delete(name) : next.add(name) |
| 67 | + return next |
| 68 | + }) |
| 69 | + } |
| 70 | + |
| 71 | + const handleImport = async () => { |
| 72 | + setImporting(true) |
| 73 | + await onImport(hosts.filter((h) => selected.has(h.name))) |
| 74 | + setImporting(false) |
| 75 | + } |
| 76 | + |
| 77 | + return ( |
| 78 | + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" |
| 79 | + onClick={onCancel}> |
| 80 | + <div |
| 81 | + className="bg-popover border border-border rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden flex flex-col" |
| 82 | + style={{ maxHeight: '80vh' }} |
| 83 | + onClick={(e) => e.stopPropagation()} |
| 84 | + > |
| 85 | + {/* Header */} |
| 86 | + <div className="flex items-center gap-3 px-5 py-4 border-b border-border shrink-0"> |
| 87 | + <div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0"> |
| 88 | + <FileCode className="w-4 h-4 text-primary" /> |
| 89 | + </div> |
| 90 | + <div className="flex-1 min-w-0"> |
| 91 | + <p className="text-sm font-semibold text-foreground">Import from SSH Config</p> |
| 92 | + <p className="text-xs text-muted-foreground mt-0.5"> |
| 93 | + Reading <code className="bg-secondary px-1 rounded text-[10px]">~/.ssh/config</code> |
| 94 | + </p> |
| 95 | + </div> |
| 96 | + <button |
| 97 | + onClick={loadCustom} |
| 98 | + className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1.5 rounded-lg hover:bg-accent cursor-pointer" |
| 99 | + > |
| 100 | + <FolderOpen className="w-3.5 h-3.5" /> Browse |
| 101 | + </button> |
| 102 | + </div> |
| 103 | + |
| 104 | + {/* Body */} |
| 105 | + <div className="flex-1 overflow-y-auto"> |
| 106 | + {loading && ( |
| 107 | + <div className="flex items-center justify-center py-16 gap-2 text-muted-foreground"> |
| 108 | + <Loader2 className="w-4 h-4 animate-spin" /> |
| 109 | + <span className="text-sm">Reading SSH config…</span> |
| 110 | + </div> |
| 111 | + )} |
| 112 | + |
| 113 | + {!loading && error && ( |
| 114 | + <div className="flex flex-col items-center gap-3 py-12 px-6 text-center"> |
| 115 | + <AlertCircle className="w-8 h-8 text-amber-400" /> |
| 116 | + <p className="text-sm text-muted-foreground">{error}</p> |
| 117 | + <button |
| 118 | + onClick={loadCustom} |
| 119 | + className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors cursor-pointer" |
| 120 | + > |
| 121 | + <FolderOpen className="w-3.5 h-3.5" /> Browse for file |
| 122 | + </button> |
| 123 | + </div> |
| 124 | + )} |
| 125 | + |
| 126 | + {!loading && !error && hosts.length > 0 && ( |
| 127 | + <div className="py-2"> |
| 128 | + {/* Select all row */} |
| 129 | + <button |
| 130 | + onClick={toggleAll} |
| 131 | + className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-accent/50 transition-colors text-left" |
| 132 | + > |
| 133 | + <div className={cn( |
| 134 | + 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors', |
| 135 | + selected.size === hosts.length ? 'bg-primary border-primary' : 'border-border' |
| 136 | + )}> |
| 137 | + {selected.size === hosts.length && <Check className="w-3 h-3 text-white" />} |
| 138 | + {selected.size > 0 && selected.size < hosts.length && ( |
| 139 | + <div className="w-2 h-0.5 bg-primary" /> |
| 140 | + )} |
| 141 | + </div> |
| 142 | + <span className="text-sm font-medium text-foreground"> |
| 143 | + Select all |
| 144 | + </span> |
| 145 | + <span className="ml-auto text-xs text-muted-foreground">{selected.size} / {hosts.length}</span> |
| 146 | + </button> |
| 147 | + |
| 148 | + <div className="h-px bg-border mx-4 my-1" /> |
| 149 | + |
| 150 | + {/* Host rows */} |
| 151 | + {hosts.map((host) => { |
| 152 | + const isSelected = selected.has(host.name) |
| 153 | + const isExpanded = expanded.has(host.name) |
| 154 | + const hasExtras = Object.keys(host.extra).length > 0 || !!host.identityFile |
| 155 | + |
| 156 | + return ( |
| 157 | + <div key={host.name}> |
| 158 | + <div className="flex items-center gap-3 px-4 py-2 hover:bg-accent/40 transition-colors"> |
| 159 | + {/* Checkbox */} |
| 160 | + <button |
| 161 | + onClick={() => toggleHost(host.name)} |
| 162 | + className={cn( |
| 163 | + 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors cursor-pointer', |
| 164 | + isSelected ? 'bg-primary border-primary' : 'border-border' |
| 165 | + )} |
| 166 | + > |
| 167 | + {isSelected && <Check className="w-3 h-3 text-white" />} |
| 168 | + </button> |
| 169 | + |
| 170 | + {/* Icon */} |
| 171 | + <div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center shrink-0"> |
| 172 | + <Server className="w-3.5 h-3.5 text-primary" /> |
| 173 | + </div> |
| 174 | + |
| 175 | + {/* Info */} |
| 176 | + <div className="flex-1 min-w-0"> |
| 177 | + <p className="text-[13px] font-medium text-foreground truncate">{host.name}</p> |
| 178 | + <p className="text-[11px] text-muted-foreground font-mono truncate"> |
| 179 | + {host.username ? `${host.username}@` : ''}{host.hostname}:{host.port} |
| 180 | + </p> |
| 181 | + </div> |
| 182 | + |
| 183 | + {/* Expand button */} |
| 184 | + {hasExtras && ( |
| 185 | + <button |
| 186 | + onClick={() => toggleExpand(host.name)} |
| 187 | + className="p-1 rounded hover:bg-accent text-muted-foreground/60 hover:text-foreground transition-colors cursor-pointer" |
| 188 | + > |
| 189 | + {isExpanded |
| 190 | + ? <ChevronDown className="w-3.5 h-3.5" /> |
| 191 | + : <ChevronRight className="w-3.5 h-3.5" /> |
| 192 | + } |
| 193 | + </button> |
| 194 | + )} |
| 195 | + </div> |
| 196 | + |
| 197 | + {/* Expanded extra fields */} |
| 198 | + {isExpanded && ( |
| 199 | + <div className="mx-4 mb-1 px-3 py-2 bg-secondary/50 rounded-lg text-[11px] font-mono text-muted-foreground space-y-0.5"> |
| 200 | + {host.identityFile && ( |
| 201 | + <p><span className="text-foreground/60">IdentityFile</span> {host.identityFile}</p> |
| 202 | + )} |
| 203 | + {Object.entries(host.extra).map(([k, v]) => ( |
| 204 | + <p key={k}><span className="text-foreground/60">{k}</span> {v}</p> |
| 205 | + ))} |
| 206 | + </div> |
| 207 | + )} |
| 208 | + </div> |
| 209 | + ) |
| 210 | + })} |
| 211 | + </div> |
| 212 | + )} |
| 213 | + </div> |
| 214 | + |
| 215 | + {/* Footer */} |
| 216 | + {!loading && !error && ( |
| 217 | + <div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border shrink-0"> |
| 218 | + <p className="text-xs text-muted-foreground"> |
| 219 | + {selected.size} host{selected.size !== 1 ? 's' : ''} selected |
| 220 | + </p> |
| 221 | + <div className="flex items-center gap-2"> |
| 222 | + <button onClick={onCancel} className="px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"> |
| 223 | + Cancel |
| 224 | + </button> |
| 225 | + <button |
| 226 | + onClick={handleImport} |
| 227 | + disabled={selected.size === 0 || importing} |
| 228 | + className="flex items-center gap-2 px-4 py-1.5 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" |
| 229 | + > |
| 230 | + {importing && <Loader2 className="w-3.5 h-3.5 animate-spin" />} |
| 231 | + Import {selected.size > 0 ? selected.size : ''} host{selected.size !== 1 ? 's' : ''} |
| 232 | + </button> |
| 233 | + </div> |
| 234 | + </div> |
| 235 | + )} |
| 236 | + </div> |
| 237 | + </div> |
| 238 | + ) |
| 239 | +} |
0 commit comments