Files
AgentHub/client/src/components/lanes/CloneHereDialog.tsx
T
nntrivi2001 57cbab916a feat(clone): background clones and uploads in one global dock that survives F5 and restarts
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.
2026-11-02 10:44:23 +07:00

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
);
}