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