From 1a6b550e898f0bfd71549bf1f0bff793949c3af2 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 19 Oct 2026 17:13:26 +0700 Subject: [PATCH] =?UTF-8?q?feat(files):=20New=20=E2=86=92=20Clone=20reposi?= =?UTF-8?q?tory=20clones=20into=20the=20open=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../src/components/lanes/CloneHereDialog.tsx | 224 ++++++++++++++++++ client/src/components/lanes/FileExplorer.tsx | 43 +++- .../lanes/__tests__/FileExplorer.test.tsx | 88 ++++++- client/src/i18n/locales/en/lanes.json | 3 + client/src/i18n/locales/vi/lanes.json | 3 + client/src/lib/api.ts | 3 + docs/API.md | 9 +- server/__tests__/home-files.test.js | 2 + server/lib/homes.js | 15 +- 9 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 client/src/components/lanes/CloneHereDialog.tsx diff --git a/client/src/components/lanes/CloneHereDialog.tsx b/client/src/components/lanes/CloneHereDialog.tsx new file mode 100644 index 00000000..be003b23 --- /dev/null +++ b/client/src/components/lanes/CloneHereDialog.tsx @@ -0,0 +1,224 @@ +/** + * @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ĩ + */ + +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(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); + 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( +
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" + /> + +
+ )} + {error && ( +

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

+ )} +
+ + +
+
+
+
, + document.body + ); +} diff --git a/client/src/components/lanes/FileExplorer.tsx b/client/src/components/lanes/FileExplorer.tsx index 7387ea9d..b0fd1693 100644 --- a/client/src/components/lanes/FileExplorer.tsx +++ b/client/src/components/lanes/FileExplorer.tsx @@ -91,6 +91,7 @@ import { confirmDialog } from "../../lib/confirm"; import { formatBytes } from "../../lib/format"; import { copyText } from "../../lib/clipboard"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; +import { CloneHereDialog } from "./CloneHereDialog"; /** One upload batch at most (the server caps one file the same). */ export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024; @@ -268,6 +269,7 @@ export function FileBrowser({ const [busy, setBusy] = useState(false); const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null); const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null); + const [cloning, setCloning] = useState(false); const [ask, setAsk] = useState(null); const [askAll, setAskAll] = useState(false); const [progress, setProgress] = useState<{ @@ -314,6 +316,18 @@ export function FileBrowser({ }, []); // Another user's home (an admin's view) is read-only. const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly; + // Clone into the open folder: the absolute path of a workspace's own folder + // or of the user's own home (never a session worktree, an extra folder or + // another user's home); the server checks the folder again. + const cloneRoot = isHomePlace(target) + ? target.home === "me" + ? (homes.find((h) => h.id === "me")?.dir ?? null) + : null + : typeof target === "number" + ? (lanes.find((l) => l.id === target)?.cwd ?? null) + : null; + const cloneParent = + cloneRoot && view === "files" ? (path ? `${cloneRoot.replace(/\/+$/, "")}/${path}` : cloneRoot) : null; /** Go to `p` in workspace `tgt`, remembering where we were for Back. */ const go = (p: string, tgt: FsPlace = target) => { @@ -633,7 +647,7 @@ export function FileBrowser({ writePref(VIEW_KEY, l); }; const onKey = (e: ReactKeyboardEvent) => { - if (renaming || creating || ask) return; + if (renaming || creating || ask || cloning) return; const mod = e.ctrlKey || e.metaKey; const k = e.key.toLowerCase(); // Another user's home is read-only: no key that writes. @@ -784,6 +798,19 @@ export function FileBrowser({ onClick={() => setMenu(null)} data-testid="file-explorer" > + {cloneParent && ( + setCloning(false)} + onCloned={(lane, at) => { + setCloning(false); + toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd })); + setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane])); + changed(); + }} + /> + )} {/* ── Places ── */}