/** * @file CloneSourcesForm — a workspace's "Or clone repositories" box, under * its Folders card: one URL per line, each cloned BESIDE the workspace folder * (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and * added as an extra folder, one after another. A host that wants a login * (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed * URLs with it, like Add workspace → Clone. Before anything runs, each URL * is checked against the user's own git identity (`GET /api/me/git`): an SSH * link while the account has no SSH key is not cloned at all — the user is * sent to Settings → Git first — and an HTTPS host with no saved login offers * the login row up front (optional: a public repo needs none). The identity is * read again when the window regains focus, so a key made in another tab * counts at once. The running clone shows its queue place, then git's * progress (`CloneProgress`); **Partial clone** sends `partial: true`. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, Loader2, X } from "lucide-react"; import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api"; import { CloneProgress, newCloneToken } from "./CloneProgress"; type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string; /** The note points at Settings → Git (no key, or a key the host refused). */ ssh?: boolean; /** While busy: the progress token CloneProgress polls. */ token?: string; }; type Login = { host: string; username: string; token: string; remember: boolean }; /** How a clone URL reaches its host: `ssh://…` or scp-like `user@host:path`, else HTTPS. */ export function cloneTarget(url: string): { kind: "ssh" | "https"; host: string } | null { const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/\/)/.exec(url); if (scp?.[1]) return { kind: "ssh", host: scp[1] }; try { const u = new URL(url); if (u.protocol === "ssh:") return { kind: "ssh", host: u.hostname }; if (u.protocol === "https:" || u.protocol === "http:") return { kind: "https", host: u.host }; } catch { /* not a URL yet — the server's allowlist answers that one */ } return null; } /** What the identity lacks for one URL: `ssh` blocks the clone, `https` only asks. */ function missingFor( url: string, id: GitIdentity | null ): { kind: "ssh" | "https"; host: string } | null { const target = cloneTarget(url); if (!id || !target) return null; if (target.kind === "ssh") { const keys = id.sshKeys?.length ?? (id.ssh ? 1 : 0); return keys > 0 ? null : target; } return id.https.some((h) => h.host === target.host) ? null : target; } export function CloneSourcesForm({ laneId, parent, onAdded, }: { laneId: number; /** Where the clones land: the workspace folder's parent, shown only. */ parent: string; onAdded: (view: LaneSourcesView) => void; }) { const { t } = useTranslation("lanes"); const [text, setText] = useState(""); const [rows, setRows] = useState([]); const [busy, setBusy] = useState(false); const [login, setLogin] = useState(null); const [partial, setPartial] = useState(false); const [identity, setIdentity] = useState(null); // An unknown identity (an older server, a failed read) checks nothing: the // server's own ECREDENTIALS / ESSHAUTH answers still cover it. const loadIdentity = useCallback( () => Promise.resolve() .then(() => api.gitIdentity.get()) .then(setIdentity) .catch(() => {}), [] ); useEffect(() => { void loadIdentity(); const onFocus = () => void loadIdentity(); window.addEventListener("focus", onFocus); return () => window.removeEventListener("focus", onFocus); }, [loadIdentity]); const urls = (s: string) => [ ...new Set( s .split("\n") .map((l) => l.trim()) .filter(Boolean) ), ]; const cloneAll = async (list: string[]) => { if (busy || !list.length) return; setBusy(true); // An SSH link with no key on the account would only fail at the host: // it is not tried, and its row says what to set up first. const next: Row[] = list.map((url) => { const miss = missingFor(url, identity); return miss?.kind === "ssh" ? { url, state: "failed", note: t("clone.sshNoKey", { host: miss.host }), ssh: true } : { url, state: "wait" }; }); const show = () => setRows([...next]); show(); let asked: Login | null = null; for (const row of next) { if (row.state === "failed") continue; row.state = "busy"; row.token = newCloneToken(); show(); try { const view = await api.lanes.cloneFolder(laneId, { url: row.url, progressToken: row.token, ...(partial ? { partial: true } : {}), ...(login?.username.trim() && login.token ? { credentials: { username: login.username.trim(), password: login.token, remember: login.remember, }, } : {}), }); row.state = "done"; onAdded(view); } catch (err) { const e = err as { code?: string; auth?: { host?: string }; message?: string }; const host = e.auth?.host || row.url; row.state = "failed"; if (e.code === "ECREDENTIALS") { row.note = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }); asked ??= { host, username: login?.username ?? "", token: "", remember: login?.remember ?? true, }; } else if (e.code === "ESSHAUTH") { row.note = t("clone.sshRefused", { host }); row.ssh = true; } else { row.note = e.message || String(err); } } row.token = undefined; show(); } if (asked) setLogin(asked); // What is left to do stays in the box; what was added leaves it. setText( next .filter((r) => r.state !== "done") .map((r) => r.url) .join("\n") ); setBusy(false); }; const failed = rows.filter((r) => r.state === "failed").map((r) => r.url); // Checked as the user types, so a missing key is said before Clone is pressed. const missing = urls(text) .map((u) => missingFor(u, identity)) .filter((m): m is NonNullable => m !== null); const sshHosts = [...new Set(missing.filter((m) => m.kind === "ssh").map((m) => m.host))]; const httpsHosts = [...new Set(missing.filter((m) => m.kind === "https").map((m) => m.host))]; const settingsLink = ( {t("clone.sshSettings")} ); return (

{t("home.cloneFolders")}