返回 DeepSeek-Reasonix
RemoteConnectWizard.tsx
根目录 / desktop / frontend / src / components / RemoteConnectWizard.tsx
1 import { useEffect, useLayoutEffect, useRef, useState } from "react";
2 import { createPortal } from "react-dom";
3 import { Check, ChevronDown, FileText, Folder, Plus } from "lucide-react";
4 import { app } from "../lib/bridge";
5 import { useT } from "../lib/i18n";
6 import { useRemoteNavigationCommand } from "../lib/remoteNavigationCommands";
7 import { useRemoteStore, waitForRemoteConnection } from "../store/remote";
8 import { RemoteStatusChip } from "./RemoteHostsPage";
9 import type { RemoteDirEntry, RemoteHostInput, RemoteHostView } from "../lib/types";
10
11 type WizardStep = "config" | "connecting" | "workspace";
12 const STEP_ORDER: WizardStep[] = ["config", "connecting", "workspace"];
13 const HOST_INPUT_ID = "remote-wizard-host-input";
14 const HOST_MENU_ID = "remote-wizard-host-menu";
15
16 const blankInput: RemoteHostInput = {
17 label: "",
18 host: "",
19 port: 22,
20 user: "",
21 identityFile: "",
22 proxyJump: "",
23 defaultWorkspace: "",
24 serveInstall: "npm",
25 credentialMode: "remote",
26 useSSHConfig: false,
27 };
28
29 function formatBytes(n: number): string {
30 if (!Number.isFinite(n) || n <= 0) return "";
31 const units = ["B", "KiB", "MiB", "GiB"];
32 let value = n;
33 let unit = 0;
34 while (value >= 1024 && unit < units.length - 1) {
35 value /= 1024;
36 unit += 1;
37 }
38 return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
39 }
40
41 function parentOf(path: string): string {
42 const trimmed = path.replace(/\/+$/, "");
43 const idx = trimmed.lastIndexOf("/");
44 if (idx <= 0) return "/";
45 return trimmed.slice(0, idx);
46 }
47
48 /**
49 * RemoteConnectWizard — three-step dialog behind the add-project "remote
50 * connection" entry. A non-interactive stepper rail on the left marks
51 * progress (number → green check); the right pane hosts the active step:
52 *
53 * 1. connection config (host suggestions from saved SSH connections,
54 * port, user, auth = password | key file, CLI download method)
55 * 2. connecting (ConnectRemoteHost + waitForRemoteConnection; TOFU and
56 * secret prompts surface through the global dialogs)
57 * 3. remote workspace picker (SFTP browse + free-text path); finish pins
58 * the canonical workspace and opens its in-app remote session tab.
59 */
60 export function RemoteConnectWizard({
61 onClose,
62 onRefresh,
63 onMerged,
64 }: {
65 onClose: () => void;
66 onRefresh: () => Promise<void>;
67 onMerged?: (message: string) => void;
68 }) {
69 const t = useT();
70 const navigateRemote = useRemoteNavigationCommand();
71 const hosts = useRemoteStore((s) => s.hosts);
72 const statuses = useRemoteStore((s) => s.statuses);
73 const setHosts = useRemoteStore((s) => s.setHosts);
74 const [step, setStep] = useState<WizardStep>("config");
75 const [form, setForm] = useState<RemoteHostInput>(blankInput);
76 const [authMode, setAuthMode] = useState<"password" | "key">("password");
77 const [pickedHostId, setPickedHostId] = useState<string | null>(null);
78 const [hostListOpen, setHostListOpen] = useState(false);
79 const [hostId, setHostId] = useState("");
80 const [connectErr, setConnectErr] = useState("");
81 const [startPath, setStartPath] = useState("~");
82 const [workspace, setWorkspace] = useState("");
83 const [entries, setEntries] = useState<RemoteDirEntry[] | null>(null);
84 const [listErr, setListErr] = useState("");
85
86 const [showHidden, setShowHidden] = useState(false);
87 const [selectedFile, setSelectedFile] = useState<string | null>(null);
88 const [logLines, setLogLines] = useState<Array<{ time: string; level: "info" | "warn"; text: string }>>([]);
89 const [busy, setBusy] = useState(false);
90 const [error, setError] = useState("");
91 const dialogRef = useRef<HTMLDivElement>(null);
92 const hostInputRef = useRef<HTMLInputElement>(null);
93 const hostToggleRef = useRef<HTMLButtonElement>(null);
94 const portInputRef = useRef<HTMLInputElement>(null);
95 const suggestRef = useRef<HTMLDivElement>(null);
96 const pendingHostMenuFocusRef = useRef<"first" | "last" | null>(null);
97 const restoreFocusRef = useRef<HTMLElement | null>(null);
98 const listRequestRef = useRef(0);
99 const host = hosts.find((h) => h.id === hostId) ?? null;
100 const pickedHost = pickedHostId ? hosts.find((h) => h.id === pickedHostId) ?? null : null;
101
102 useLayoutEffect(() => {
103 restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
104 hostInputRef.current?.focus();
105 return () => {
106 if (restoreFocusRef.current?.isConnected) restoreFocusRef.current.focus();
107 };
108 }, []);
109
110 useEffect(() => {
111 let cancelled = false;
112 void app
113 .RemoteHosts()
114 .then((list) => {
115 if (!cancelled) setHosts(list);
116 })
117 .catch(() => {});
118 return () => {
119 cancelled = true;
120 };
121 }, [setHosts]);
122
123 useEffect(() => () => {
124 listRequestRef.current += 1;
125 }, []);
126
127 useLayoutEffect(() => {
128 const edge = pendingHostMenuFocusRef.current;
129 if (!hostListOpen || !edge) return;
130 pendingHostMenuFocusRef.current = null;
131 const items = Array.from(
132 suggestRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
133 );
134 (edge === "first" ? items[0] : items[items.length - 1])?.focus();
135 }, [hostListOpen, hosts.length]);
136
137 useEffect(() => {
138 const onKey = (event: KeyboardEvent) => {
139 if (event.key === "Escape" && !busy) {
140 event.preventDefault();
141 event.stopPropagation();
142 // With the saved-host list open, the first Escape closes the list;
143 // only the next one exits the wizard.
144 if (hostListOpen) {
145 setHostListOpen(false);
146 hostToggleRef.current?.focus();
147 return;
148 }
149 onClose();
150 return;
151 }
152 if (
153 event.key === "Tab" &&
154 hostListOpen &&
155 document.activeElement instanceof HTMLElement &&
156 document.activeElement.getAttribute("role") === "menuitem"
157 ) {
158 event.preventDefault();
159 setHostListOpen(false);
160 (event.shiftKey ? hostToggleRef.current : portInputRef.current)?.focus();
161 return;
162 }
163 if (event.key !== "Tab") return;
164 const focusable = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(
165 'input:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])',
166 ) ?? []);
167 if (focusable.length === 0) return;
168 const first = focusable[0];
169 const last = focusable[focusable.length - 1];
170 if (!dialogRef.current?.contains(document.activeElement)) {
171 event.preventDefault();
172 (event.shiftKey ? last : first).focus();
173 } else if (event.shiftKey && document.activeElement === first) {
174 event.preventDefault();
175 last.focus();
176 } else if (!event.shiftKey && document.activeElement === last) {
177 event.preventDefault();
178 first.focus();
179 }
180 };
181 document.addEventListener("keydown", onKey, { capture: true });
182 return () => document.removeEventListener("keydown", onKey, { capture: true });
183 }, [busy, hostListOpen, onClose]);
184
185 // The saved-host dropdown only closes on explicit dismissal: a pick, the
186 // arrow, Escape, or a pointer press outside the host field wrapper.
187 useEffect(() => {
188 if (!hostListOpen) return;
189 const onPointerDown = (event: PointerEvent) => {
190 const wrapper = suggestRef.current;
191 if (wrapper && event.target && !wrapper.contains(event.target as Node)) {
192 setHostListOpen(false);
193 }
194 };
195 document.addEventListener("pointerdown", onPointerDown, true);
196 return () => document.removeEventListener("pointerdown", onPointerDown, true);
197 }, [hostListOpen]);
198
199 const set = <K extends keyof RemoteHostInput>(key: K, value: RemoteHostInput[K]) =>
200 setForm((current) => ({ ...current, [key]: value }));
201
202 const pickSaved = (saved: RemoteHostView) => {
203 setForm({
204 label: saved.label,
205 host: saved.host,
206 port: saved.port,
207 user: saved.user,
208 identityFile: saved.identityFile,
209 proxyJump: saved.proxyJump,
210 defaultWorkspace: saved.defaultWorkspace,
211 serveInstall: saved.serveInstall,
212 credentialMode: saved.credentialMode || "remote",
213 useSSHConfig: saved.useSSHConfig,
214 });
215 setAuthMode(saved.identityFile ? "key" : "password");
216 setPickedHostId(saved.id);
217 setHostListOpen(false);
218 hostInputRef.current?.focus();
219 };
220
221 const openDir = async (id: string, path: string) => {
222 if (!id) return;
223 const requestId = ++listRequestRef.current;
224 setWorkspace(path);
225 setEntries(null);
226 setListErr("");
227 try {
228 const nextEntries = await app.ListRemoteDir(id, path);
229 if (requestId !== listRequestRef.current) return;
230 setEntries(nextEntries);
231 } catch (e) {
232 if (requestId !== listRequestRef.current) return;
233 setEntries([]);
234 setListErr(e instanceof Error ? e.message : String(e));
235 }
236 };
237
238 const logRef = useRef<HTMLDivElement>(null);
239
240 const pushLog = (level: "info" | "warn", text: string) => {
241 const now = new Date();
242 const time = [now.getHours(), now.getMinutes(), now.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
243 setLogLines((current) => [...current, { time, level, text }]);
244 };
245
246 useEffect(() => {
247 if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
248 }, [logLines]);
249
250 // targetHost comes from the caller: the render closure's `host` still
251 // reflects the pre-save hostId on the first connect, which logged the raw
252 // host id instead of user@host.
253 const connect = async (id: string, startPath: string, targetHost: RemoteHostView | null) => {
254 setBusy(true);
255 setConnectErr("");
256 setLogLines([]);
257 setStep("connecting");
258 const target = targetHost ? `${targetHost.user ? `${targetHost.user}@` : ""}${targetHost.host}${targetHost.port && targetHost.port !== 22 ? `:${targetHost.port}` : ""}` : id;
259 pushLog("info", t("remoteWizard.logConnecting", { target }));
260 try {
261 await app.ConnectRemoteHost(id);
262 pushLog("info", t("remoteWizard.logDetecting"));
263 await waitForRemoteConnection(id);
264 pushLog("info", t("remoteWizard.logConnected"));
265 // Reject unsupported remote OSes (V1: Linux/macOS) before directory
266 // browsing; the error keeps the wizard on the connecting step.
267 await app.CheckRemotePlatform(id);
268 pushLog("info", t("remoteWizard.logPrepare"));
269 setStep("workspace");
270 void openDir(id, startPath);
271 } catch (e) {
272 const message = e instanceof Error ? e.message : String(e);
273 pushLog("warn", t("remoteWizard.logFailed", { error: message }));
274 setConnectErr(message);
275 } finally {
276 setBusy(false);
277 }
278 };
279
280 const nextFromConfig = async () => {
281 if (busy) return;
282 const hostValue = form.host.trim();
283 const userValue = form.user.trim();
284 const missing: string[] = [];
285 if (!hostValue) missing.push(t("remote.host.host"));
286 if (!userValue) missing.push(t("remoteWizard.userShort"));
287 if (authMode === "password" && !form.password?.trim() && !pickedHost?.passwordSet) {
288 missing.push(t("remoteWizard.authPassword"));
289 }
290 if (authMode === "key" && !form.identityFile.trim()) {
291 missing.push(t("remoteWizard.identityFileShort"));
292 }
293 if (missing.length > 0) {
294 setError(t("remoteWizard.required", { fields: missing.join(t("remoteWizard.requiredJoin")) }));
295 return;
296 }
297 setBusy(true);
298 setError("");
299 try {
300 const input: RemoteHostInput = {
301 ...form,
302 host: hostValue,
303 label: form.label.trim() || hostValue,
304 password: form.password || undefined,
305 keyPassphrase: form.keyPassphrase || undefined,
306 };
307 const saved = pickedHostId ? await app.UpdateRemoteHost(pickedHostId, input) : await app.AddRemoteHost(input);
308 setHosts(await app.RemoteHosts());
309 setPickedHostId(saved.id);
310 setHostId(saved.id);
311 const nextStartPath =
312 form.defaultWorkspace.trim() ||
313 (await app.RemoteLastWorkspace(saved.id).catch(() => "")) ||
314 "~";
315 setStartPath(nextStartPath);
316 await connect(saved.id, nextStartPath, saved);
317 } catch (e) {
318 setError(e instanceof Error ? e.message : String(e));
319 setStep("config");
320 } finally {
321 setBusy(false);
322 }
323 };
324
325
326
327 const finish = async () => {
328 const target = workspace.trim();
329 if (!hostId || !target || busy) return;
330 setBusy(true);
331 setError("");
332 try {
333 let project: Awaited<ReturnType<typeof app.AddRemoteProject>> | null = null;
334 try {
335 project = await app.AddRemoteProject(hostId, target);
336 } catch (e) {
337 setError(e instanceof Error ? e.message : String(e));
338 return;
339 }
340 const canonical = project.merged ? project.workspace : target;
341 if (project.merged) onMerged?.(t("remoteWizard.mergedProject", { path: canonical }));
342 try {
343 const outcome = await navigateRemote({ hostId, workspace: canonical }, { newSession: true });
344 if (outcome.status === "cancelled") return;
345 if (outcome.status === "failed") throw outcome.error;
346 } catch (e) {
347 setError(e instanceof Error ? e.message : String(e));
348 if (!project.merged) {
349 try {
350 await app.RemoveRemoteProject(hostId, target);
351 } catch {
352 await onRefresh().catch(() => {});
353 }
354 }
355 return;
356 }
357 await onRefresh();
358 onClose();
359 } finally {
360 setBusy(false);
361 }
362 };
363
364 const stepIndex = STEP_ORDER.indexOf(step);
365
366 return createPortal(
367 <div
368 data-app-overlay=""
369 className="modal-backdrop remote-wizard-backdrop"
370 role="presentation"
371 onMouseDown={(event) => {
372 if (!busy && event.target === event.currentTarget) onClose();
373 }}
374 >
375 <div ref={dialogRef} className="modal remote-wizard" role="dialog" aria-modal="true" aria-label={t("remoteWizard.title")}>
376 <div className="modal__title">{t("remoteWizard.title")}</div>
377 <div className="remote-wizard__frame">
378 <div className="remote-wizard__rail" aria-hidden="true">
379 {STEP_ORDER.map((name, index) => {
380 const done = index < stepIndex;
381 const current = name === step;
382 return (
383 <div
384 key={name}
385 className={`remote-wizard__rail-item${current ? " remote-wizard__rail-item--current" : ""}${done ? " remote-wizard__rail-item--done" : ""}`}
386 >
387 <span className="remote-wizard__rail-index">{done ? <Check size={12} aria-hidden="true" /> : index + 1}</span>
388 <span className="remote-wizard__rail-label">
389 {name === "config" ? t("remoteWizard.stepConfig") : name === "connecting" ? t("remoteWizard.stepConnecting") : t("remoteWizard.stepWorkspace")}
390 </span>
391 </div>
392 );
393 })}
394 </div>
395
396 <div className="remote-wizard__body">
397 {step === "config" ? (
398 <>
399 <div className="remote-wizard__form">
400 <div className="remote-wizard__field-row">
401 <div className="remote-wizard__suggest" ref={suggestRef}>
402 <div className="remote-wizard__field">
403 <label htmlFor={HOST_INPUT_ID}>{t("remote.host.host")}</label>
404 <div className="remote-wizard__host-box">
405 <input
406 id={HOST_INPUT_ID}
407 ref={hostInputRef}
408 value={form.host}
409 disabled={busy}
410 autoComplete="off"
411 placeholder={t("remoteWizard.hostPlaceholder")}
412 onChange={(event) => {
413 set("host", event.target.value);
414 setPickedHostId(null);
415 }}
416 />
417 {hosts.length > 0 ? (
418 <button
419 ref={hostToggleRef}
420 type="button"
421 className={`remote-wizard__suggest-toggle${hostListOpen ? " remote-wizard__suggest-toggle--open" : ""}`}
422 disabled={busy}
423 aria-haspopup="menu"
424 aria-expanded={hostListOpen}
425 aria-controls={HOST_MENU_ID}
426 title={t("remoteWizard.suggestions")}
427 onClick={() => {
428 if (hostListOpen) {
429 pendingHostMenuFocusRef.current = null;
430 setHostListOpen(false);
431 return;
432 }
433 pendingHostMenuFocusRef.current = "first";
434 setHostListOpen(true);
435 }}
436 onKeyDown={(event) => {
437 if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
438 event.preventDefault();
439 pendingHostMenuFocusRef.current = event.key === "ArrowDown" ? "first" : "last";
440 if (hostListOpen) {
441 const items = Array.from(
442 suggestRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
443 );
444 pendingHostMenuFocusRef.current = null;
445 (event.key === "ArrowDown" ? items[0] : items[items.length - 1])?.focus();
446 } else {
447 setHostListOpen(true);
448 }
449 }}
450 >
451 <ChevronDown size={14} aria-hidden="true" />
452 </button>
453 ) : null}
454 </div>
455 </div>
456 {hostListOpen && hosts.length > 0 ? (
457 <div
458 className="remote-wizard__suggest-list"
459 role="menu"
460 id={HOST_MENU_ID}
461 aria-label={t("remoteWizard.suggestions")}
462 onKeyDown={(event) => {
463 if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
464 event.preventDefault();
465 event.stopPropagation();
466 const items = Array.from(
467 event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'),
468 );
469 if (items.length === 0) return;
470 const current = items.indexOf(document.activeElement as HTMLButtonElement);
471 const next =
472 event.key === "Home"
473 ? 0
474 : event.key === "End"
475 ? items.length - 1
476 : event.key === "ArrowDown"
477 ? (current + 1) % items.length
478 : (current - 1 + items.length) % items.length;
479 items[next]?.focus();
480 }}
481 >
482 <div className="remote-wizard__suggest-head" aria-hidden="true">{t("remoteWizard.suggestions")}</div>
483 {hosts.map((saved) => (
484 <button
485 key={saved.id}
486 type="button"
487 role="menuitem"
488 tabIndex={-1}
489 onClick={() => pickSaved(saved)}
490 >
491 <span className="remote-wizard__suggest-label">{saved.label}</span>
492 <span className="remote-wizard__suggest-detail">
493 {saved.user ? `${saved.user}@` : ""}
494 {saved.host}
495 {saved.port && saved.port !== 22 ? `:${saved.port}` : ""}
496 </span>
497 </button>
498 ))}
499 </div>
500 ) : null}
501 </div>
502 <label className="remote-wizard__field">
503 <span>{t("remote.host.port")}</span>
504 <input
505 ref={portInputRef}
506 type="text"
507 inputMode="numeric"
508 placeholder="22"
509 value={form.port === 0 ? "" : String(form.port)}
510 disabled={busy}
511 onChange={(event) => {
512 const digits = event.target.value.replace(/[^0-9]/g, "").slice(0, 5);
513 set("port", digits ? Number(digits) : 0);
514 }}
515 />
516 </label>
517 </div>
518 <div className="remote-wizard__field-row">
519 <label className="remote-wizard__field">
520 <span>{t("remote.host.user")}</span>
521 <input value={form.user} disabled={busy} placeholder={t("remoteWizard.userPlaceholder")} onChange={(event) => set("user", event.target.value)} />
522 </label>
523 <div className="remote-wizard__field">
524 <span>{t("remoteWizard.authMethod")}</span>
525 <div className="provider-add-segmented remote-wizard__seg" role="group" aria-label={t("remoteWizard.authMethod")}>
526 <button
527 type="button"
528 className={`provider-add-segmented__item${authMode === "password" ? " provider-add-segmented__item--active" : ""}`}
529 disabled={busy}
530 onClick={() => setAuthMode("password")}
531 >
532 {t("remoteWizard.authPassword")}
533 </button>
534 <button
535 type="button"
536 className={`provider-add-segmented__item${authMode === "key" ? " provider-add-segmented__item--active" : ""}`}
537 disabled={busy}
538 onClick={() => setAuthMode("key")}
539 >
540 {t("remoteWizard.authKey")}
541 </button>
542 </div>
543 </div>
544 </div>
545 {authMode === "password" ? (
546 <label className="remote-wizard__field">
547 <span>{t("remoteWizard.authPassword")}</span>
548 <input
549 type="password"
550 autoComplete="new-password"
551 placeholder={pickedHost?.passwordSet ? t("remote.host.credentialSavedPlaceholder") : t("remoteWizard.passwordPlaceholder")}
552 value={form.password ?? ""}
553 disabled={busy}
554 onChange={(event) => setForm((current) => ({ ...current, password: event.target.value, clearPassword: false }))}
555 />
556 </label>
557 ) : (
558 <>
559 <label className="remote-wizard__field">
560 <span>{t("remote.host.identityFile")}</span>
561 <div className="remote-wizard__identity-row">
562 <input
563 value={form.identityFile}
564 disabled={busy}
565 placeholder={t("remoteWizard.identityPlaceholder")}
566 onChange={(event) => set("identityFile", event.target.value)}
567 />
568 <button
569 type="button"
570 className="remote-wizard__pick-btn"
571 disabled={busy}
572 aria-label={t("remoteWizard.pickIdentityFile")}
573 title={t("remoteWizard.pickIdentityFile")}
574 onClick={() => {
575 void app.PickRemoteIdentityFile().then((path) => {
576 if (path) set("identityFile", path);
577 }).catch((e) => {
578 setError(e instanceof Error ? e.message : String(e));
579 });
580 }}
581 >
582 <Plus size={14} aria-hidden="true" />
583 </button>
584 </div>
585 </label>
586 <label className="remote-wizard__field">
587 <span>{t("remote.host.keyPassphrase")}</span>
588 <input
589 type="password"
590 autoComplete="new-password"
591 placeholder={t("remoteWizard.passphrasePlaceholder")}
592 value={form.keyPassphrase ?? ""}
593 disabled={busy}
594 onChange={(event) => setForm((current) => ({ ...current, keyPassphrase: event.target.value, clearPassphrase: false }))}
595 />
596 </label>
597 </>
598 )}
599 <div className="remote-wizard__field">
600 <span>{t("remoteWizard.downloadMethod")}</span>
601 <div className="provider-add-segmented remote-wizard__seg" role="group" aria-label={t("remoteWizard.downloadMethod")}>
602 <button
603 type="button"
604 className={`provider-add-segmented__item${form.serveInstall === "upload" ? " provider-add-segmented__item--active" : ""}`}
605 disabled={busy}
606 onClick={() => set("serveInstall", "upload")}
607 >
608 {t("remoteWizard.downloadUpload")}
609 </button>
610 <button
611 type="button"
612 className={`provider-add-segmented__item${form.serveInstall === "npm" ? " provider-add-segmented__item--active" : ""}`}
613 disabled={busy}
614 onClick={() => set("serveInstall", "npm")}
615 >
616 {t("remoteWizard.downloadRemote")}
617 </button>
618 </div>
619 </div>
620 <div className="remote-wizard__field">
621 <span>{t("remote.host.credentialMode")}</span>
622 <div className="provider-add-segmented remote-wizard__seg" role="group" aria-label={t("remote.host.credentialMode")}>
623 <button
624 type="button"
625 className={`provider-add-segmented__item${form.credentialMode !== "local-proxy" ? " provider-add-segmented__item--active" : ""}`}
626 disabled={busy}
627 onClick={() => set("credentialMode", "remote")}
628 >
629 {t("remote.host.credentialModeRemote")}
630 </button>
631 <button
632 type="button"
633 className={`provider-add-segmented__item${form.credentialMode === "local-proxy" ? " provider-add-segmented__item--active" : ""}`}
634 disabled={busy}
635 onClick={() => set("credentialMode", "local-proxy")}
636 >
637 {t("remote.host.credentialModeLocalProxy")}
638 </button>
639 </div>
640 </div>
641 </div>
642 </>
643 ) : null}
644
645 {step === "connecting" ? (
646 <div className="remote-wizard__connecting">
647 <span className="remote-wizard__connecting-title">
648 {host ? `${host.label} · ${host.user ? `${host.user}@` : ""}${host.host}` : ""}
649 </span>
650 <RemoteStatusChip state={statuses[hostId]?.state ?? "connecting"} />
651 <div className="remote-wizard__log" ref={logRef} role="log" aria-label={t("remoteWizard.stepConnecting")}>
652 {logLines.map((line, index) => (
653 <div key={index} className="remote-wizard__log-line">
654 <span className="remote-wizard__log-time">{line.time}</span>
655 <span className={`remote-wizard__log-level remote-wizard__log-level--${line.level}`}>{line.level.toUpperCase()}</span>
656 <span className="remote-wizard__log-text">{line.text}</span>
657 </div>
658 ))}
659 </div>
660 {connectErr ? (
661 <>
662 <div className="remote-wizard__error" role="alert">
663 {connectErr}
664 </div>
665 <div className="remote-wizard__connecting-actions">
666 <button type="button" className="btn btn--small" onClick={() => setStep("config")}>
667 {t("remoteWizard.backToEdit")}
668 </button>
669 <button type="button" className="btn btn--small btn--primary" onClick={() => void connect(hostId, startPath, host)}>
670 {t("remoteWizard.retry")}
671 </button>
672 </div>
673 </>
674 ) : (
675 <span className="remote-wizard__connecting-hint">{t("remoteWizard.connecting")}</span>
676 )}
677 </div>
678 ) : null}
679
680 {step === "workspace" ? (
681 <>
682 <div className="remote-wizard__workspace-head">
683 <div className="remote-wizard__workspace-title">{t("remoteWizard.workspaceIntro")}</div>
684 <div className="remote-wizard__workspace-ready">
685 <span className="remote-wizard__ready-dot" aria-hidden="true" />
686 {t("remoteWizard.workspaceReady")}
687 </div>
688 </div>
689 <div className="remote-wizard__browse">{t("remoteWizard.browseFolder")}</div>
690 <div className="remote-wizard__path-bar">
691 <input
692 className="remote-wizard__path-input"
693 value={workspace}
694 disabled={busy}
695 placeholder={t("remoteWizard.path")}
696 onChange={(event) => setWorkspace(event.target.value)}
697 />
698 <button type="button" className="btn btn--small" disabled={busy || !workspace.trim()} onClick={() => void openDir(hostId, workspace.trim() || "~")}>
699 {t("remoteWizard.go")}
700 </button>
701 <button type="button" className="btn btn--small" disabled={busy} onClick={() => setShowHidden((value) => !value)}>
702 {t(showHidden ? "remoteWizard.toggleHiddenOn" : "remoteWizard.toggleHidden")}
703 </button>
704 </div>
705 <div className="remote-wizard__tree">
706 {entries === null && !listErr ? <div className="remote-wizard__empty">{t("common.loading")}</div> : null}
707 {listErr ? (
708 <div className="remote-wizard__error" role="alert">
709 {listErr}
710 </div>
711 ) : null}
712 {entries?.length === 0 && !listErr ? <div className="remote-wizard__empty">{t("remoteWizard.emptyDir")}</div> : null}
713 {workspace && workspace !== "/" && workspace !== "~" ? (
714 <button
715 type="button"
716 className="remote-wizard__dir"
717 onClick={() => {
718 setSelectedFile(null);
719 void openDir(hostId, parentOf(workspace) === "/" ? "~" : parentOf(workspace));
720 }}
721 >
722 <Folder size={13} className="remote-wizard__dir-icon" aria-hidden="true" />
723 <span className="remote-wizard__dir-name">..</span>
724 </button>
725 ) : null}
726 {(entries ?? [])
727 .filter((entry) => showHidden || !entry.name.startsWith("."))
728 .map((entry) => entry.isDir ? { entry, rank: 0 } : { entry, rank: 1 })
729 .sort((a, b) => a.rank - b.rank || a.entry.name.localeCompare(b.entry.name))
730 .map(({ entry }) =>
731 entry.isDir ? (
732 <button
733 key={entry.path}
734 type="button"
735 className={`remote-wizard__dir${workspace === entry.path && !selectedFile ? " remote-wizard__dir--selected" : ""}`}
736 onClick={() => {
737 setSelectedFile(null);
738 void openDir(hostId, entry.path);
739 }}
740 >
741 <Folder size={13} className="remote-wizard__dir-icon" aria-hidden="true" />
742 <span className="remote-wizard__dir-name">{entry.name}</span>
743 </button>
744 ) : (
745 <button
746 key={entry.path}
747 type="button"
748 className={`remote-wizard__file${selectedFile === entry.path ? " remote-wizard__file--selected" : ""}`}
749 onClick={() => {
750 setSelectedFile(entry.path);
751 setWorkspace(parentOf(entry.path));
752 }}
753 >
754 <FileText size={13} className="remote-wizard__dir-icon" aria-hidden="true" />
755 <span className="remote-wizard__dir-name">{entry.name}</span>
756 <span className="remote-wizard__file-size">{formatBytes(entry.size)}</span>
757 </button>
758 ),
759 )}
760 </div>
761
762 </>
763 ) : null}
764 </div>
765 </div>
766 <div className="remote-wizard__footer">
767 {error ? (
768 <div className="remote-wizard__error" role="alert">
769 <span className="remote-wizard__error-mark" aria-hidden="true">⚠</span>
770 {error}
771 </div>
772 ) : (
773 <div className="remote-wizard__error" />
774 )}
775 <div className="modal__actions remote-wizard__actions">
776 {step !== "config" ? (
777 <button type="button" className="btn btn--small" disabled={busy} onClick={() => setStep("config")}>
778 {t("remoteWizard.back")}
779 </button>
780 ) : null}
781 <button type="button" className="btn btn--small" disabled={busy} onClick={onClose}>
782 {t("common.cancel")}
783 </button>
784 {step === "config" ? (
785 <button type="button" className="btn btn--small btn--primary" disabled={busy} onClick={() => void nextFromConfig()}>
786 {busy ? t("common.loading") : t("remoteWizard.next")}
787 </button>
788 ) : null}
789 {step === "workspace" ? (
790 <button type="button" className="btn btn--small btn--primary" disabled={busy || !workspace.trim()} onClick={() => void finish()}>
791 {busy ? t("remoteWizard.connecting") : t("remoteWizard.finish")}
792 </button>
793 ) : null}
794 </div>
795 </div>
796 </div>
797 </div>,
798 document.body,
799 );
800 }
801
801 lines Plain Text