/** * @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. * @author Nguyễn Ngọc Trí Vĩ */ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, Loader2, X } from "lucide-react"; import { api, type LaneSourcesView } from "../../lib/api"; type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string }; type Login = { host: string; username: string; token: string; remember: boolean }; 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 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); const next: Row[] = list.map((url) => ({ url, state: "wait" })); const show = () => setRows([...next]); show(); let asked: Login | null = null; for (const row of next) { row.state = "busy"; show(); try { const view = await api.lanes.cloneFolder(laneId, { url: row.url, ...(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 }); } else { row.note = e.message || String(err); } } 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); return (

{t("home.cloneFolders")}