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