返回 DeepSeek-Reasonix
RemoteHostsPage.tsx
根目录 / desktop / frontend / src / components / RemoteHostsPage.tsx
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
484 lines Plain Text