| 1 | import { SettingsSelect } from "./SettingsSelect"; |
| 2 | import { Pencil, Trash2 } from "lucide-react"; |
| 3 | import { useCallback, useEffect, useState } from "react"; |
| 4 | |
| 5 | import { useConfirmDialog } from "./ConfirmDialog"; |
| 6 | import { app } from "../lib/bridge"; |
| 7 | import { useT } from "../lib/i18n"; |
| 8 | import { isRemoteDegradedWarning, remoteConnectionErrorSummaryKey } from "../lib/remoteErrors"; |
| 9 | import { useRemoteStore } from "../store/remote"; |
| 10 | import type { RemoteConnectionStatus, RemoteHostInput, RemoteHostView, RemoteConnState, RemoteLegacyWorkbenchData } from "../lib/types"; |
| 11 | |
| 12 | const EMPTY_INPUT: RemoteHostInput = { |
| 13 | label: "", |
| 14 | host: "", |
| 15 | port: 22, |
| 16 | user: "", |
| 17 | identityFile: "", |
| 18 | proxyJump: "", |
| 19 | defaultWorkspace: "", |
| 20 | serveInstall: "auto", |
| 21 | credentialMode: "remote", |
| 22 | useSSHConfig: false, |
| 23 | }; |
| 24 | |
| 25 | type Screen = { kind: "list" } | { kind: "add" } | { kind: "edit"; id: string } | { kind: "import" }; |
| 26 | |
| 27 | /** RemoteHostsPage is the Settings > Remote SSH manager: host list with |
| 28 | * connect/disconnect + add/edit/remove + ssh_config import. */ |
| 29 | export function RemoteHostsPage() { |
| 30 | const t = useT(); |
| 31 | const [hosts, setHosts] = useState<RemoteHostView[]>([]); |
| 32 | const [screen, setScreen] = useState<Screen>({ kind: "list" }); |
| 33 | const [pageError, setPageError] = useState(""); |
| 34 | const [legacyData, setLegacyData] = useState<RemoteLegacyWorkbenchData | null>(null); |
| 35 | const [legacyBusy, setLegacyBusy] = useState<"" | "mirrors" | "trust">(""); |
| 36 | const { confirm, dialog: confirmDialog } = useConfirmDialog(); |
| 37 | const statuses = useRemoteStore((s) => s.statuses); |
| 38 | const setStoreHosts = useRemoteStore((s) => s.setHosts); |
| 39 | const hydrateStatuses = useRemoteStore((s) => s.hydrateStatuses); |
| 40 | const openExplorer = useRemoteStore((s) => s.openExplorer); |
| 41 | |
| 42 | const refreshLegacy = useCallback(async () => { |
| 43 | try { |
| 44 | const view = await app.ScanRemoteLegacyWorkbenchData(); |
| 45 | setLegacyData(view.mirrorCount > 0 || view.trustFile ? view : null); |
| 46 | } catch { |
| 47 | setLegacyData(null); |
| 48 | } |
| 49 | }, []); |
| 50 | |
| 51 | useEffect(() => { |
| 52 | void refreshLegacy(); |
| 53 | }, [refreshLegacy]); |
| 54 | |
| 55 | const cleanLegacy = useCallback(async (target: "mirrors" | "trust") => { |
| 56 | const confirmed = await confirm({ |
| 57 | title: t("remote.legacyData.cleanTitle"), |
| 58 | message: target === "mirrors" ? t("remote.legacyData.cleanMirrorsConfirm") : t("remote.legacyData.cleanTrustConfirm"), |
| 59 | confirmLabel: t("remote.legacyData.clean"), |
| 60 | cancelLabel: t("remote.host.cancel"), |
| 61 | tone: "danger", |
| 62 | }); |
| 63 | if (!confirmed) return; |
| 64 | setLegacyBusy(target); |
| 65 | try { |
| 66 | await app.CleanRemoteLegacyWorkbenchData(target); |
| 67 | await refreshLegacy(); |
| 68 | } catch (error) { |
| 69 | setPageError(String(error)); |
| 70 | } finally { |
| 71 | setLegacyBusy(""); |
| 72 | } |
| 73 | }, [confirm, refreshLegacy, t]); |
| 74 | |
| 75 | const refresh = useCallback(async () => { |
| 76 | const next = await app.RemoteHosts(); |
| 77 | setHosts(next); |
| 78 | setStoreHosts(next); |
| 79 | }, [setStoreHosts]); |
| 80 | |
| 81 | useEffect(() => { |
| 82 | void refresh().catch((error) => setPageError(String(error))); |
| 83 | void app.RemoteConnectionStatuses().then(hydrateStatuses).catch((error) => setPageError(String(error))); |
| 84 | }, [refresh, hydrateStatuses]); |
| 85 | |
| 86 | if (screen.kind === "add" || screen.kind === "edit") { |
| 87 | const editingHost = screen.kind === "edit" ? hosts.find((h) => h.id === screen.id) : undefined; |
| 88 | const initial = |
| 89 | screen.kind === "edit" ? hostToInput(editingHost) : EMPTY_INPUT; |
| 90 | return ( |
| 91 | <RemoteHostForm |
| 92 | initial={initial} |
| 93 | editingId={screen.kind === "edit" ? screen.id : null} |
| 94 | passwordSet={editingHost?.passwordSet ?? false} |
| 95 | keyPassphraseSet={editingHost?.keyPassphraseSet ?? false} |
| 96 | onDone={async () => { |
| 97 | await refresh(); |
| 98 | setScreen({ kind: "list" }); |
| 99 | }} |
| 100 | onCancel={() => setScreen({ kind: "list" })} |
| 101 | /> |
| 102 | ); |
| 103 | } |
| 104 | if (screen.kind === "import") { |
| 105 | return ( |
| 106 | <RemoteSSHConfigImport |
| 107 | onDone={async () => { |
| 108 | await refresh(); |
| 109 | setScreen({ kind: "list" }); |
| 110 | }} |
| 111 | onCancel={() => setScreen({ kind: "list" })} |
| 112 | /> |
| 113 | ); |
| 114 | } |
| 115 | |
| 116 | return ( |
| 117 | <> |
| 118 | <div className="remote-hosts"> |
| 119 | <div className="remote-hosts__toolbar settings-toolbar"> |
| 120 | <h2>{t("remote.hosts.title")}</h2> |
| 121 | <div className="remote-hosts__actions"> |
| 122 | <button className="btn" onClick={() => setScreen({ kind: "import" })}> |
| 123 | {t("remote.hosts.import")} |
| 124 | </button> |
| 125 | <button className="btn btn--primary" onClick={() => setScreen({ kind: "add" })}> |
| 126 | {t("remote.hosts.add")} |
| 127 | </button> |
| 128 | </div> |
| 129 | </div> |
| 130 | {pageError && <p className="remote-host-form__error" role="alert">{pageError}</p>} |
| 131 | {hosts.length === 0 ? ( |
| 132 | <p className="remote-hosts__empty">{t("remote.hosts.empty")}</p> |
| 133 | ) : ( |
| 134 | <ul className="remote-hosts__list"> |
| 135 | {hosts.map((h) => ( |
| 136 | <RemoteHostRow |
| 137 | key={h.id} |
| 138 | host={h} |
| 139 | status={statuses[h.id]} |
| 140 | onConnect={() => void app.ConnectRemoteHost(h.id).catch(() => {})} |
| 141 | onDisconnect={() => void app.DisconnectRemoteHost(h.id).catch(() => {})} |
| 142 | onOpen={() => openExplorer(h.id)} |
| 143 | onEdit={() => setScreen({ kind: "edit", id: h.id })} |
| 144 | onRemove={async () => { |
| 145 | const confirmed = await confirm({ |
| 146 | title: t("remote.host.removeConfirmTitle"), |
| 147 | message: t("remote.host.removeConfirm", { host: h.label }), |
| 148 | confirmLabel: t("remote.host.remove"), |
| 149 | cancelLabel: t("remote.host.cancel"), |
| 150 | tone: "danger", |
| 151 | }); |
| 152 | if (!confirmed) return; |
| 153 | setPageError(""); |
| 154 | try { |
| 155 | await app.RemoveRemoteHost(h.id); |
| 156 | await refresh(); |
| 157 | } catch (error) { |
| 158 | setPageError(String(error)); |
| 159 | } |
| 160 | }} |
| 161 | /> |
| 162 | ))} |
| 163 | </ul> |
| 164 | )} |
| 165 | {legacyData && ( |
| 166 | <section className="remote-hosts__legacy" aria-label={t("remote.legacyData.title")}> |
| 167 | <h3>{t("remote.legacyData.title")}</h3> |
| 168 | <p>{t("remote.legacyData.summary", { count: legacyData.mirrorCount, size: formatLegacyBytes(legacyData.mirrorBytes) })}</p> |
| 169 | {legacyData.trustFile && <p>{t("remote.legacyData.trustPresent")}</p>} |
| 170 | <div className="remote-hosts__legacy-actions"> |
| 171 | <button |
| 172 | className="btn btn--danger" |
| 173 | disabled={legacyBusy !== "" || legacyData.mirrorCount === 0} |
| 174 | onClick={() => void cleanLegacy("mirrors")} |
| 175 | > |
| 176 | {legacyBusy === "mirrors" ? t("remote.legacyData.cleaning") : t("remote.legacyData.cleanMirrors")} |
| 177 | </button> |
| 178 | <button |
| 179 | className="btn btn--danger" |
| 180 | disabled={legacyBusy !== "" || !legacyData.trustFile} |
| 181 | onClick={() => void cleanLegacy("trust")} |
| 182 | > |
| 183 | {legacyBusy === "trust" ? t("remote.legacyData.cleaning") : t("remote.legacyData.cleanTrust")} |
| 184 | </button> |
| 185 | </div> |
| 186 | </section> |
| 187 | )} |
| 188 | </div> |
| 189 | {confirmDialog} |
| 190 | </> |
| 191 | ); |
| 192 | |
| 193 | function formatLegacyBytes(n: number): string { |
| 194 | if (!Number.isFinite(n) || n <= 0) return "0 B"; |
| 195 | const units = ["B", "KiB", "MiB", "GiB"]; |
| 196 | let value = n; |
| 197 | let unit = 0; |
| 198 | while (value >= 1024 && unit < units.length - 1) { |
| 199 | value /= 1024; |
| 200 | unit += 1; |
| 201 | } |
| 202 | return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`; |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | function RemoteHostRow(props: { |
| 207 | host: RemoteHostView; |
| 208 | status?: RemoteConnectionStatus; |
| 209 | onConnect: () => void; |
| 210 | onDisconnect: () => void; |
| 211 | onOpen: () => void; |
| 212 | onEdit: () => void; |
| 213 | onRemove: () => void; |
| 214 | }) { |
| 215 | const t = useT(); |
| 216 | const { host } = props; |
| 217 | const state = props.status?.state; |
| 218 | const target = `${host.user ? host.user + "@" : ""}${host.host}${host.port && host.port !== 22 ? ":" + host.port : ""}`; |
| 219 | const connected = state === "connected" || state === "degraded"; |
| 220 | const degradedWarning = isRemoteDegradedWarning(props.status); |
| 221 | return ( |
| 222 | <li className="remote-host-row"> |
| 223 | <div className="remote-host-row__main"> |
| 224 | <span className="remote-host-row__name">{host.label}</span> |
| 225 | <span className="remote-host-row__target">{target}</span> |
| 226 | {state && <RemoteStatusChip state={state} />} |
| 227 | {props.status?.error && ( |
| 228 | <span className={`remote-panel__error ${degradedWarning ? "remote-panel__error--warning" : ""}`}> |
| 229 | {t(remoteConnectionErrorSummaryKey(props.status), { host: host.label })} |
| 230 | </span> |
| 231 | )} |
| 232 | </div> |
| 233 | <div className="remote-host-row__actions"> |
| 234 | {connected ? ( |
| 235 | <> |
| 236 | <button className="btn" onClick={props.onOpen}> |
| 237 | {t("remote.explorer")} |
| 238 | </button> |
| 239 | <button className="btn" onClick={props.onDisconnect}> |
| 240 | {t("remote.disconnect")} |
| 241 | </button> |
| 242 | </> |
| 243 | ) : ( |
| 244 | <button className="btn btn--primary" onClick={props.onConnect}> |
| 245 | {t("remote.connect")} |
| 246 | </button> |
| 247 | )} |
| 248 | <button className="btn settings-icon-button" title={t("remote.host.edit")} aria-label={t("remote.host.edit")} onClick={props.onEdit}> |
| 249 | <Pencil size={16} aria-hidden="true" /> |
| 250 | </button> |
| 251 | <button className="btn settings-icon-button" title={t("remote.host.remove")} aria-label={t("remote.host.remove")} onClick={props.onRemove}> |
| 252 | <Trash2 size={16} aria-hidden="true" /> |
| 253 | </button> |
| 254 | </div> |
| 255 | </li> |
| 256 | ); |
| 257 | } |
| 258 | |
| 259 | export function RemoteStatusChip({ state }: { state: RemoteConnState }) { |
| 260 | const t = useT(); |
| 261 | return ( |
| 262 | <span className={`remote-chip remote-chip--${state}`} aria-label={t(`remote.status.${state}`)}> |
| 263 | {t(`remote.status.${state}`)} |
| 264 | </span> |
| 265 | ); |
| 266 | } |
| 267 | |
| 268 | function RemoteHostForm(props: { |
| 269 | initial: RemoteHostInput; |
| 270 | editingId: string | null; |
| 271 | passwordSet: boolean; |
| 272 | keyPassphraseSet: boolean; |
| 273 | onDone: () => void; |
| 274 | onCancel: () => void; |
| 275 | }) { |
| 276 | const t = useT(); |
| 277 | const [form, setForm] = useState<RemoteHostInput>(props.initial); |
| 278 | const [busy, setBusy] = useState(false); |
| 279 | const [err, setErr] = useState(""); |
| 280 | |
| 281 | const set = <K extends keyof RemoteHostInput>(k: K, v: RemoteHostInput[K]) => |
| 282 | setForm((f) => ({ ...f, [k]: v })); |
| 283 | |
| 284 | const submit = async () => { |
| 285 | setBusy(true); |
| 286 | setErr(""); |
| 287 | try { |
| 288 | if (props.editingId) await app.UpdateRemoteHost(props.editingId, form); |
| 289 | else await app.AddRemoteHost(form); |
| 290 | props.onDone(); |
| 291 | } catch (e) { |
| 292 | setErr(String(e)); |
| 293 | } finally { |
| 294 | setBusy(false); |
| 295 | } |
| 296 | }; |
| 297 | |
| 298 | return ( |
| 299 | <div className="remote-host-form"> |
| 300 | <label> |
| 301 | {t("remote.host.label")} |
| 302 | <input value={form.label} disabled={!!props.editingId} onChange={(e) => set("label", e.target.value)} /> |
| 303 | </label> |
| 304 | <label> |
| 305 | {t("remote.host.host")} |
| 306 | <input value={form.host} onChange={(e) => set("host", e.target.value)} /> |
| 307 | </label> |
| 308 | <label> |
| 309 | {t("remote.host.port")} |
| 310 | <input type="number" min={form.useSSHConfig ? 0 : 1} max={65535} value={form.port} onChange={(e) => set("port", Number(e.target.value) || 0)} /> |
| 311 | </label> |
| 312 | <label> |
| 313 | <input type="checkbox" checked={form.useSSHConfig} onChange={(e) => set("useSSHConfig", e.target.checked)} /> |
| 314 | {t("remote.host.useSSHConfig")} |
| 315 | </label> |
| 316 | <label> |
| 317 | {t("remote.host.user")} |
| 318 | <input value={form.user} onChange={(e) => set("user", e.target.value)} /> |
| 319 | </label> |
| 320 | <label> |
| 321 | {t("remote.host.identityFile")} |
| 322 | <input value={form.identityFile} onChange={(e) => set("identityFile", e.target.value)} /> |
| 323 | </label> |
| 324 | <div className="remote-host-form__credential"> |
| 325 | <label> |
| 326 | {t("remote.host.password")} |
| 327 | <input |
| 328 | type="password" |
| 329 | autoComplete="new-password" |
| 330 | value={form.password ?? ""} |
| 331 | placeholder={props.passwordSet ? t("remote.host.credentialSavedPlaceholder") : ""} |
| 332 | onChange={(e) => setForm((current) => ({ ...current, password: e.target.value, clearPassword: false }))} |
| 333 | /> |
| 334 | </label> |
| 335 | <div className="remote-host-form__credential-meta"> |
| 336 | <span> |
| 337 | {form.clearPassword |
| 338 | ? t("remote.host.passwordRemoveHint") |
| 339 | : props.passwordSet |
| 340 | ? t("remote.host.passwordSavedHint") |
| 341 | : t("remote.host.passwordHint")} |
| 342 | </span> |
| 343 | {props.passwordSet && ( |
| 344 | <button |
| 345 | className="btn btn--small" |
| 346 | type="button" |
| 347 | onClick={() => setForm((current) => ({ ...current, password: "", clearPassword: !current.clearPassword }))} |
| 348 | > |
| 349 | {form.clearPassword ? t("remote.host.keepPassword") : t("remote.host.clearPassword")} |
| 350 | </button> |
| 351 | )} |
| 352 | </div> |
| 353 | </div> |
| 354 | <div className="remote-host-form__credential"> |
| 355 | <label> |
| 356 | {t("remote.host.keyPassphrase")} |
| 357 | <input |
| 358 | type="password" |
| 359 | autoComplete="new-password" |
| 360 | value={form.keyPassphrase ?? ""} |
| 361 | placeholder={props.keyPassphraseSet ? t("remote.host.credentialSavedPlaceholder") : ""} |
| 362 | onChange={(e) => setForm((current) => ({ ...current, keyPassphrase: e.target.value, clearPassphrase: false }))} |
| 363 | /> |
| 364 | </label> |
| 365 | <div className="remote-host-form__credential-meta"> |
| 366 | <span> |
| 367 | {form.clearPassphrase |
| 368 | ? t("remote.host.keyPassphraseRemoveHint") |
| 369 | : props.keyPassphraseSet |
| 370 | ? t("remote.host.keyPassphraseSavedHint") |
| 371 | : t("remote.host.keyPassphraseHint")} |
| 372 | </span> |
| 373 | {props.keyPassphraseSet && ( |
| 374 | <button |
| 375 | className="btn btn--small" |
| 376 | type="button" |
| 377 | onClick={() => setForm((current) => ({ ...current, keyPassphrase: "", clearPassphrase: !current.clearPassphrase }))} |
| 378 | > |
| 379 | {form.clearPassphrase ? t("remote.host.keepKeyPassphrase") : t("remote.host.clearKeyPassphrase")} |
| 380 | </button> |
| 381 | )} |
| 382 | </div> |
| 383 | </div> |
| 384 | <label> |
| 385 | {t("remote.host.proxyJump")} |
| 386 | <input value={form.proxyJump} onChange={(e) => set("proxyJump", e.target.value)} /> |
| 387 | </label> |
| 388 | <label> |
| 389 | {t("remote.host.defaultWorkspace")} |
| 390 | <input value={form.defaultWorkspace} onChange={(e) => set("defaultWorkspace", e.target.value)} /> |
| 391 | </label> |
| 392 | <label> |
| 393 | {t("remote.host.serveInstall")} |
| 394 | <SettingsSelect value={form.serveInstall} onValueChange={(value) => set("serveInstall", value)}> |
| 395 | <option value="auto">auto</option> |
| 396 | <option value="npm">npm</option> |
| 397 | <option value="upload">upload</option> |
| 398 | <option value="never">never</option> |
| 399 | </SettingsSelect> |
| 400 | </label> |
| 401 | <label> |
| 402 | {t("remote.host.credentialMode")} |
| 403 | <SettingsSelect value={form.credentialMode} onValueChange={(value) => set("credentialMode", value)}> |
| 404 | <option value="remote">{t("remote.host.credentialModeRemote")}</option> |
| 405 | <option value="local-proxy">{t("remote.host.credentialModeLocalProxy")}</option> |
| 406 | </SettingsSelect> |
| 407 | </label> |
| 408 | {err && <p className="remote-host-form__error" role="alert">{err}</p>} |
| 409 | <div className="remote-host-form__actions"> |
| 410 | <button className="btn" onClick={props.onCancel}>{t("remote.host.cancel")}</button> |
| 411 | <button className="btn btn--primary" disabled={busy || !form.label.trim() || !form.host.trim() || (!form.useSSHConfig && form.port < 1) || form.port > 65535} onClick={() => void submit()}> |
| 412 | {t("remote.host.save")} |
| 413 | </button> |
| 414 | </div> |
| 415 | </div> |
| 416 | ); |
| 417 | } |
| 418 | |
| 419 | function RemoteSSHConfigImport(props: { onDone: () => void; onCancel: () => void }) { |
| 420 | const t = useT(); |
| 421 | const [candidates, setCandidates] = useState<RemoteHostInput[]>([]); |
| 422 | const [selected, setSelected] = useState<Record<string, boolean>>({}); |
| 423 | const [busy, setBusy] = useState(false); |
| 424 | const [err, setErr] = useState(""); |
| 425 | |
| 426 | useEffect(() => { |
| 427 | void app.ScanSSHConfig().then(setCandidates).catch((e) => setErr(String(e))); |
| 428 | }, []); |
| 429 | |
| 430 | const importSelected = async () => { |
| 431 | setBusy(true); |
| 432 | setErr(""); |
| 433 | try { |
| 434 | for (const c of candidates) { |
| 435 | if (selected[c.label]) await app.AddRemoteHost(c); |
| 436 | } |
| 437 | props.onDone(); |
| 438 | } catch (e) { |
| 439 | setErr(String(e)); |
| 440 | } finally { |
| 441 | setBusy(false); |
| 442 | } |
| 443 | }; |
| 444 | |
| 445 | return ( |
| 446 | <div className="remote-import"> |
| 447 | {err && <p className="remote-host-form__error" role="alert">{err}</p>} |
| 448 | {candidates.length === 0 ? ( |
| 449 | <p className="remote-hosts__empty">{t("remote.hosts.importEmpty")}</p> |
| 450 | ) : ( |
| 451 | <ul className="remote-import__list"> |
| 452 | {candidates.map((c) => ( |
| 453 | <li key={c.label}> |
| 454 | <label> |
| 455 | <input |
| 456 | type="checkbox" |
| 457 | checked={!!selected[c.label]} |
| 458 | onChange={(e) => setSelected((s) => ({ ...s, [c.label]: e.target.checked }))} |
| 459 | /> |
| 460 | {c.label} — {c.user ? c.user + "@" : ""}{c.host} |
| 461 | </label> |
| 462 | </li> |
| 463 | ))} |
| 464 | </ul> |
| 465 | )} |
| 466 | <div className="remote-host-form__actions"> |
| 467 | <button className="btn" onClick={props.onCancel}>{t("remote.host.cancel")}</button> |
| 468 | <button className="btn btn--primary" disabled={busy || !Object.values(selected).some(Boolean)} onClick={() => void importSelected()}> |
| 469 | {t("remote.hosts.importSelected")} |
| 470 | </button> |
| 471 | </div> |
| 472 | </div> |
| 473 | ); |
| 474 | } |
| 475 | |
| 476 | function hostToInput(h?: RemoteHostView): RemoteHostInput { |
| 477 | if (!h) return EMPTY_INPUT; |
| 478 | return { |
| 479 | label: h.label, |
| 480 | host: h.host, |
| 481 | port: h.port, |
| 482 | user: h.user, |
| 483 | identityFile: h.identityFile, |
| 484 | proxyJump: h.proxyJump, |
| 485 | defaultWorkspace: h.defaultWorkspace, |
| 486 | serveInstall: h.serveInstall, |
| 487 | credentialMode: h.credentialMode || "remote", |
| 488 | useSSHConfig: h.useSSHConfig, |
| 489 | password: "", |
| 490 | keyPassphrase: "", |
| 491 | clearPassword: false, |
| 492 | clearPassphrase: false, |
| 493 | }; |
| 494 | } |
| 495 |