| 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 |