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