/** * @file CloneHereDialog.tsx * @description The Files page's **New → Clone repository**: clones a URL into * the folder the File Explorer has open, through the same * `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL * to its allowlist and the folder to the user's own, and clones as the * folder's account with that account's own SSH key / HTTPS login — and opens * a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent * once, optionally remembered), `ESSHAUTH` points to Settings → Git. * A running clone shows `CloneProgress` and never holds the page: closing the * dialog only hides it behind a small progress chip (click to reopen), the * request keeps going, and its outcome arrives as a toast — a failure is kept * for the next open so a login row is still there to fill in. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { FolderGit2 } from "lucide-react"; import { api } from "../../lib/api"; import type { Lane } from "../../lib/types"; import { useDialog } from "../../hooks/useDialog"; import { cloneDirName } from "./AddLaneModal"; import { CloneProgress, newCloneToken } from "./CloneProgress"; import { toast } from "../../lib/toast"; type Login = { host: string; username: string; token: string; remember: boolean }; export function CloneHereDialog({ open, parent, onClose, onCloned, onReopen, }: { open: boolean; /** The absolute folder the clone goes into. */ parent: string; onClose: () => void; onCloned: (lane: Lane, path: string) => void; /** Show the dialog again from the background chip. */ onReopen: () => void; }) { const { t } = useTranslation(["lanes"]); const panelRef = useRef(null); const titleId = useId(); const [url, setUrl] = useState(""); const [name, setName] = useState(""); const [login, setLogin] = useState(null); const [sshRefused, setSshRefused] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [token, setToken] = useState(null); /** Where the running clone goes (the open folder may change meanwhile). */ const [target, setTarget] = useState(""); // Read by the request's own continuation and by the reset below. const openRef = useRef(open); openRef.current = open; const busyRef = useRef(false); /** A clone failed while hidden: the next open shows that, not a blank form. */ const keepRef = useRef(false); useDialog(open, panelRef, onClose); useEffect(() => { if (!open || busyRef.current) return; if (keepRef.current) { keepRef.current = false; return; } setUrl(""); setName(""); setLogin(null); setSshRefused(false); setBusy(false); setError(null); }, [open, parent]); const folder = name.trim() || cloneDirName(url); const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`; if (!open) { if (!busy || !token) return null; return createPortal( , document.body ); } const submit = async () => { const u = url.trim(); if (!u || busy) return; const progressToken = newCloneToken(); busyRef.current = true; setBusy(true); setToken(progressToken); setTarget(into); setError(null); setSshRefused(false); try { const result = await api.lanes.clone({ url: u, parent, name: name.trim() || undefined, progressToken, ...(login?.username.trim() && login.token ? { credentials: { username: login.username.trim(), password: login.token, remember: login.remember, }, } : {}), }); busyRef.current = false; setBusy(false); setToken(null); onCloned(result.lane, result.path); } catch (err) { const e = err as { code?: string; auth?: { host?: string }; message?: string }; const host = e.auth?.host || u; let message: string; if (e.code === "ECREDENTIALS") { message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }); setLogin((l) => ({ host, username: l?.username ?? "", token: "", remember: l?.remember ?? true, })); } else if (e.code === "ESSHAUTH") { setSshRefused(true); message = t("clone.sshRefused", { host }); } else { message = err instanceof Error ? err.message : String(err); } setError(message); busyRef.current = false; setBusy(false); setToken(null); if (!openRef.current) { keepRef.current = true; toast.error(message); } } }; return createPortal(
e.stopPropagation()} role="presentation" >
e.stopPropagation()} >

{t("fx.cloneRepo")}

{ e.preventDefault(); void submit(); }} className="space-y-3" >
setUrl(e.target.value)} placeholder="https://host/owner/repo.git" disabled={busy} className="input w-full font-mono text-xs" />

{t("clone.urlHint")}

setName(e.target.value)} placeholder={cloneDirName(url)} disabled={busy} className="input w-full font-mono text-xs" />

{t("clone.parentHint", { path: into })}

{login && (
setLogin({ ...login, username: e.target.value })} disabled={busy} className="input w-full text-xs" /> setLogin({ ...login, token: e.target.value })} disabled={busy} className="input w-full text-xs" />
)} {busy && token && } {error && (

{error}{" "} {sshRefused && ( {t("clone.sshSettings")} )}

)}
, document.body ); }