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