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