1a6b550e89
The File Explorer's New menu (and its right-click menu) gains Clone repository: it clones a URL into the folder it has open — of the user's own home or of a workspace's own folder — through POST /api/lanes/clone with that folder as `parent`, so the URL allowlist, the own-folder check and the folder account's own git identity all apply, and opens a workspace on the clone. ECREDENTIALS asks for a login, ESSHAUTH points to Settings → Git. GET /api/homes now gives `me` its absolute `dir`. Keys typed in the dialog never reach the explorer's shortcuts (Backspace / Delete).
225 lines
7.7 KiB
TypeScript
225 lines
7.7 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. A 409 `ECREDENTIALS` opens a login row (sent
|
|
* once, optionally remembered), `ESSHAUTH` points 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 { api } from "../../lib/api";
|
|
import type { Lane } from "../../lib/types";
|
|
import { useDialog } from "../../hooks/useDialog";
|
|
import { cloneDirName } from "./AddLaneModal";
|
|
|
|
type Login = { host: string; username: string; token: string; remember: boolean };
|
|
|
|
export function CloneHereDialog({
|
|
open,
|
|
parent,
|
|
onClose,
|
|
onCloned,
|
|
}: {
|
|
open: boolean;
|
|
/** The absolute folder the clone goes into. */
|
|
parent: string;
|
|
onClose: () => void;
|
|
onCloned: (lane: Lane, path: string) => 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, busy ? () => {} : 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 {
|
|
const result = await api.lanes.clone({
|
|
url: u,
|
|
parent,
|
|
name: name.trim() || undefined,
|
|
...(login?.username.trim() && login.token
|
|
? {
|
|
credentials: {
|
|
username: login.username.trim(),
|
|
password: login.token,
|
|
remember: login.remember,
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
onCloned(result.lane, result.path);
|
|
} 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={busy ? undefined : 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>
|
|
)}
|
|
<div className="flex justify-end gap-2">
|
|
<button type="button" onClick={onClose} disabled={busy} 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("clone.cloning") : t("fx.cloneSubmit")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|