57cbab916a
Clone repository (Files), Add workspace -> Clone and a workspace's extra-folder clones now start server-side jobs (background: true) and close at once; the app-wide BackgroundJobsDock (Layout) shows each job's queue place, git progress, Stop (click twice), and outcome: Open workspace, Retry, an inline login form on ECREDENTIALS, a Settings -> Git link on ESSHAUTH, dismiss. lib/cloneJobs.ts polls GET /workspaces/clone/jobs while any job is active, announces a finish it saw once (toast + agenthub:clone-job event + the starter's onDone), and treats an older server's synchronous answer as a finished clone. File Explorer uploads report to lib/uploadJobs.ts (Stop aborts the request in flight; a reload-cut upload shows as interrupted from localStorage). CloneProgress renders a job's fields instead of polling a token.
239 lines
8.1 KiB
TypeScript
239 lines
8.1 KiB
TypeScript
/**
|
|
* @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. The clone is a server-side background job
|
|
* (`lib/cloneJobs.ts`): the dialog closes as soon as the server accepted it
|
|
* and the app-wide dock (`BackgroundJobsDock`) carries its progress and
|
|
* outcome, across pages, F5 and server restarts. A refusal before the clone
|
|
* starts (a bad URL, a folder that is not the user's, a 409 `ECREDENTIALS` /
|
|
* `ESSHAUTH`) stays in the form: a login row (sent once, optionally
|
|
* remembered), or a pointer to Settings → Git.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useId, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FolderGit2 } from "lucide-react";
|
|
import { useDialog } from "../../hooks/useDialog";
|
|
import { cloneDirName } from "./AddLaneModal";
|
|
import { startClone } from "../../lib/cloneJobs";
|
|
|
|
type Login = { host: string; username: string; token: string; remember: boolean };
|
|
|
|
export function CloneHereDialog({
|
|
open,
|
|
parent,
|
|
onClose,
|
|
}: {
|
|
open: boolean;
|
|
/** The absolute folder the clone goes into. */
|
|
parent: string;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const titleId = useId();
|
|
const [url, setUrl] = useState("");
|
|
const [name, setName] = useState("");
|
|
const [login, setLogin] = useState<Login | null>(null);
|
|
const [sshRefused, setSshRefused] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
useDialog(open, panelRef, onClose);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setUrl("");
|
|
setName("");
|
|
setLogin(null);
|
|
setSshRefused(false);
|
|
setBusy(false);
|
|
setError(null);
|
|
}, [open, parent]);
|
|
|
|
if (!open) return null;
|
|
|
|
const folder = name.trim() || cloneDirName(url);
|
|
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
|
|
|
const submit = async () => {
|
|
const u = url.trim();
|
|
if (!u || busy) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
setSshRefused(false);
|
|
try {
|
|
await startClone({
|
|
kind: "workspace",
|
|
body: {
|
|
url: u,
|
|
parent,
|
|
name: name.trim() || undefined,
|
|
...(login?.username.trim() && login.token
|
|
? {
|
|
credentials: {
|
|
username: login.username.trim(),
|
|
password: login.token,
|
|
remember: login.remember,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
});
|
|
// Accepted: the dock takes it from here.
|
|
onClose();
|
|
} catch (err) {
|
|
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
|
const host = e.auth?.host || u;
|
|
if (e.code === "ECREDENTIALS") {
|
|
setError(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);
|
|
setError(t("clone.sshRefused", { host }));
|
|
} else {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
|
onClick={onClose}
|
|
// A portal still bubbles through React: keep keys away from the File
|
|
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={titleId}
|
|
data-testid="fx-clone-dialog"
|
|
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h2
|
|
id={titleId}
|
|
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
|
>
|
|
<FolderGit2 className="h-4 w-4" />
|
|
{t("fx.cloneRepo")}
|
|
</h2>
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void submit();
|
|
}}
|
|
className="space-y-3"
|
|
>
|
|
<div>
|
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-url`}>
|
|
{t("clone.urlLabel")}
|
|
</label>
|
|
<input
|
|
id={`${titleId}-url`}
|
|
autoFocus
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder="https://host/owner/repo.git"
|
|
disabled={busy}
|
|
className="input w-full font-mono text-xs"
|
|
/>
|
|
<p className="mt-1 text-[11px] text-fg-muted">{t("clone.urlHint")}</p>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-name`}>
|
|
{t("clone.nameLabel")}
|
|
</label>
|
|
<input
|
|
id={`${titleId}-name`}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={cloneDirName(url)}
|
|
disabled={busy}
|
|
className="input w-full font-mono text-xs"
|
|
/>
|
|
<p className="mt-1 break-all text-[11px] text-fg-muted">
|
|
{t("clone.parentHint", { path: into })}
|
|
</p>
|
|
</div>
|
|
{login && (
|
|
<div
|
|
data-testid="fx-clone-login"
|
|
className="space-y-2 rounded-md border border-border p-2"
|
|
>
|
|
<input
|
|
aria-label={t("clone.username")}
|
|
placeholder={t("clone.username")}
|
|
autoComplete="username"
|
|
value={login.username}
|
|
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
|
disabled={busy}
|
|
className="input w-full text-xs"
|
|
/>
|
|
<input
|
|
aria-label={t("clone.token")}
|
|
placeholder={t("clone.token")}
|
|
type="password"
|
|
autoComplete="current-password"
|
|
value={login.token}
|
|
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
|
disabled={busy}
|
|
className="input w-full text-xs"
|
|
/>
|
|
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
|
<input
|
|
type="checkbox"
|
|
checked={login.remember}
|
|
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
|
disabled={busy}
|
|
/>
|
|
{t("clone.remember")}
|
|
</label>
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<p role="alert" className="text-xs text-status-danger">
|
|
{error}{" "}
|
|
{sshRefused && (
|
|
<a href="/settings/git#git-identity" className="underline">
|
|
{t("clone.sshSettings")}
|
|
</a>
|
|
)}
|
|
</p>
|
|
)}
|
|
<p className="text-[11px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
|
|
<div className="flex justify-end gap-2">
|
|
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
|
{t("fx.cancel")}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={busy || !url.trim()}
|
|
className="btn-primary text-xs"
|
|
data-testid="fx-clone-submit"
|
|
>
|
|
{busy ? t("cloneJobs.starting") : t("fx.cloneSubmit")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|