feat(files): New → Clone repository clones into the open folder

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).
This commit is contained in:
2026-10-19 17:13:26 +07:00
parent f8d07dab61
commit 1a6b550e89
9 changed files with 384 additions and 6 deletions
+42 -1
View File
@@ -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<Ask | null>(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 && (
<CloneHereDialog
open={cloning}
parent={cloneParent}
onClose={() => 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 ── */}
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
@@ -1018,6 +1045,17 @@ export function FileBrowser({
>
{t("fx.newFile")}
</DropItem>
{cloneParent && (
<DropItem
icon={<FolderGit2 className="h-4 w-4" />}
onClick={() => {
setMenu(null);
setCloning(true);
}}
>
{t("fx.cloneRepo")}
</DropItem>
)}
</DropButton>
<DropButton
label={t("fx.upload")}
@@ -1449,6 +1487,9 @@ export function FileBrowser({
{t("fx.newFolder")}
</MenuItem>
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
{cloneParent && (
<MenuItem onSelect={close(() => setCloning(true))}>{t("fx.cloneRepo")}</MenuItem>
)}
<MenuSeparator />
<MenuItem onSelect={close(() => fileInput.current?.click())}>
{t("fx.uploadFiles")}