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.
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @file BackgroundJobsDock.tsx
|
||||
* @description The one bottom-right stack of background work, rendered once
|
||||
* by `Layout`: a chip per background clone (`lib/cloneJobs.ts`, kept by the
|
||||
* server, so it is back after F5 or a server restart) and per File Explorer
|
||||
* upload (`lib/uploadJobs.ts`). A clone chip shows its queue place, then
|
||||
* git's progress, with **Stop** (click twice) while it runs; once finished,
|
||||
* **Open workspace** (done), the reason plus **Retry** (failed, interrupted,
|
||||
* stopped) — an inline login form when the host wants one, a link to
|
||||
* Settings → Git when it refused the SSH key — and × to dismiss. An upload
|
||||
* chip shows its progress with **Stop**; one a reload cut short says how far
|
||||
* it got. Several chips collapse to one small pill.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, FolderGit2, Square, Upload, X } from "lucide-react";
|
||||
import type { CloneJob } from "../lib/api";
|
||||
import {
|
||||
cancelCloneJob,
|
||||
cloneJobError,
|
||||
dismissCloneJob,
|
||||
ensureCloneJobs,
|
||||
isActiveJob,
|
||||
retryCloneJob,
|
||||
useCloneJobs,
|
||||
} from "../lib/cloneJobs";
|
||||
import { dismissUpload, stopUpload, useUploadJobs, type UploadJob } from "../lib/uploadJobs";
|
||||
import { useArmed } from "../hooks/useArmed";
|
||||
import { CloneProgress } from "./lanes/CloneProgress";
|
||||
|
||||
const chipClass =
|
||||
"pointer-events-auto w-full space-y-1.5 rounded-lg border border-border bg-surface-1 p-2.5 text-xs shadow-xl shadow-black/40";
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
danger,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
danger?: boolean;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
className={`-m-0.5 shrink-0 rounded p-1 hover:bg-surface-2 ${
|
||||
danger ? "text-status-danger" : "text-fg-muted hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CloneJobChip({ job }: { job: CloneJob }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { arm, isArmed, disarm } = useArmed();
|
||||
const [login, setLogin] = useState({ username: "", token: "", remember: true });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const active = isActiveJob(job);
|
||||
const code = job.error?.code;
|
||||
const laneId = job.kind === "workspace" ? (job.result?.laneId ?? null) : job.laneId;
|
||||
const title =
|
||||
job.state === "done"
|
||||
? t("cloneJobs.done", { name: job.name })
|
||||
: job.state === "cancelled"
|
||||
? t("cloneJobs.cancelledTitle", { name: job.name })
|
||||
: active
|
||||
? t("cloneJobs.cloning", { name: job.name })
|
||||
: t("cloneJobs.failedTitle", { name: job.name });
|
||||
|
||||
const retry = async (withLogin: boolean) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await retryCloneJob(
|
||||
job.id,
|
||||
withLogin
|
||||
? { username: login.username.trim(), password: login.token, remember: login.remember }
|
||||
: undefined
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!isArmed("stop")) return arm("stop");
|
||||
disarm();
|
||||
void cancelCloneJob(job.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={chipClass} data-testid="clone-job" data-state={job.state} title={job.target}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">{title}</span>
|
||||
{active ? (
|
||||
<IconButton
|
||||
label={isArmed("stop") ? t("cloneJobs.stopConfirm") : t("cloneJobs.stop")}
|
||||
onClick={stop}
|
||||
danger={isArmed("stop")}
|
||||
testId="clone-job-stop"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
label={t("cloneJobs.dismiss")}
|
||||
onClick={() => void dismissCloneJob(job.id)}
|
||||
testId="clone-job-dismiss"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{active && isArmed("stop") && (
|
||||
<p className="text-[11px] text-status-danger">{t("cloneJobs.stopConfirm")}</p>
|
||||
)}
|
||||
{active && <CloneProgress view={job} />}
|
||||
{job.state === "done" && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-mono text-[10px] text-fg-muted">
|
||||
{job.result?.path || job.target}
|
||||
</span>
|
||||
{laneId !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-accent hover:underline"
|
||||
onClick={() => {
|
||||
navigate(`/workspace?lane=${laneId}`);
|
||||
void dismissCloneJob(job.id);
|
||||
}}
|
||||
>
|
||||
{job.kind === "workspace" ? t("cloneJobs.openWorkspace") : t("cloneJobs.open")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!active && job.state !== "done" && (
|
||||
<>
|
||||
<p
|
||||
role={job.state === "cancelled" ? undefined : "alert"}
|
||||
className={`text-[11px] ${
|
||||
job.state === "cancelled" ? "text-fg-secondary" : "text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{cloneJobError(job)}{" "}
|
||||
{code === "ESSHAUTH" && (
|
||||
<a href="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
{code === "ECREDENTIALS" && job.state === "failed" ? (
|
||||
<form
|
||||
data-testid="clone-job-login"
|
||||
className="space-y-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void retry(true);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="username"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
autoComplete="current-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !login.username.trim() || !login.token}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("cloneJobs.retryLogin")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
code !== "ESSHAUTH" && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void retry(false)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("cloneJobs.retry")}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadChip({ job }: { job: UploadJob }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const { arm, isArmed, disarm } = useArmed();
|
||||
if (job.state === "interrupted") {
|
||||
return (
|
||||
<div className={chipClass} data-testid="upload-job" data-state="interrupted">
|
||||
<div className="flex items-start gap-2">
|
||||
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary">
|
||||
{t("uploads.interrupted", {
|
||||
total: job.total,
|
||||
folder: job.folder,
|
||||
done: job.done,
|
||||
})}
|
||||
</p>
|
||||
<IconButton label={t("cloneJobs.dismiss")} onClick={() => dismissUpload(job.id)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const stopping = job.state === "stopping";
|
||||
return (
|
||||
<div className={chipClass} data-testid="fx-progress" data-state={job.state}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">
|
||||
{stopping
|
||||
? t("uploads.stopping")
|
||||
: t("fx.uploading", { done: Math.min(job.done + 1, job.total), total: job.total })}
|
||||
</span>
|
||||
{!stopping && (
|
||||
<IconButton
|
||||
label={isArmed("stop") ? t("uploads.stopConfirm") : t("uploads.stop")}
|
||||
danger={isArmed("stop")}
|
||||
testId="upload-job-stop"
|
||||
onClick={() => {
|
||||
if (!isArmed("stop")) return arm("stop");
|
||||
disarm();
|
||||
stopUpload(job.id);
|
||||
}}
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{isArmed("stop") && !stopping && (
|
||||
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
|
||||
)}
|
||||
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
|
||||
{job.folder}
|
||||
{job.name ? ` · ${job.name}` : ""}
|
||||
</p>
|
||||
<div className="h-1.5 overflow-hidden rounded bg-surface-3">
|
||||
<div
|
||||
className="h-full bg-accent-solid transition-[width]"
|
||||
style={{ width: `${Math.min(100, (job.sent / Math.max(1, job.bytes)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundJobsDock() {
|
||||
const { t } = useTranslation("lanes");
|
||||
const jobs = useCloneJobs();
|
||||
const uploads = useUploadJobs();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
useEffect(() => ensureCloneJobs(), []);
|
||||
|
||||
const count = jobs.length + uploads.length;
|
||||
if (!count) return null;
|
||||
const running =
|
||||
jobs.filter(isActiveJob).length + uploads.filter((u) => u.state !== "interrupted").length;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("jobs.dock")}
|
||||
data-testid="jobs-dock"
|
||||
className="pointer-events-none fixed bottom-10 right-4 z-[60] flex max-h-[70vh] w-[min(20rem,calc(100vw-2rem))] flex-col items-end gap-2 overflow-y-auto"
|
||||
>
|
||||
{count > 1 && collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="jobs-dock-pill"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title={t("jobs.expand")}
|
||||
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border bg-surface-1 px-3 py-1 text-xs text-fg-primary shadow-lg shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-fg-muted" />
|
||||
{t("jobs.count", { count })}
|
||||
{running > 0 && (
|
||||
<span className="text-fg-muted">· {t("jobs.running", { count: running })}</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{count > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="jobs-dock-collapse"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-full border border-border bg-surface-1 px-2 py-0.5 text-[11px] text-fg-secondary shadow hover:bg-surface-2"
|
||||
>
|
||||
{t("jobs.count", { count })}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{uploads.map((u) => (
|
||||
<UploadChip key={u.id} job={u} />
|
||||
))}
|
||||
{jobs.map((j) => (
|
||||
<CloneJobChip key={j.id} job={j} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./BackgroundJobsDock` (background clones and uploads, app-wide)
|
||||
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
|
||||
* - `./Sagi/SagiMount`
|
||||
*
|
||||
@@ -82,6 +83,7 @@ import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { BackgroundJobsDock } from "./BackgroundJobsDock";
|
||||
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
@@ -182,6 +184,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<BackgroundJobsDock />
|
||||
<InterruptedSessionsPrompt />
|
||||
<SagiMount />
|
||||
{onboarding.loaded && !onboarding.onboardedAt && (
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @file Tests for the background jobs store (`lib/cloneJobs.ts`,
|
||||
* `lib/uploadJobs.ts`) and its dock: a clone started in the background is
|
||||
* announced once when the store sees it finish (toast + window event); a
|
||||
* fresh mount (F5) shows what the server kept — an interrupted clone with
|
||||
* Retry, a login failure with an inline login form that retries with it —
|
||||
* without toasting it again; × dismisses; Stop (click twice) cancels a
|
||||
* running clone, which then reads as stopped and can be retried; an older
|
||||
* server that cannot stop says so. An upload shows with Stop, and one a
|
||||
* reload cut short is shown as interrupted until dismissed. Several chips
|
||||
* collapse to one pill.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../i18n";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
clone: vi.fn(),
|
||||
cloneFolder: vi.fn(),
|
||||
cloneJobs: vi.fn(),
|
||||
retryCloneJob: vi.fn(),
|
||||
dismissCloneJob: vi.fn(),
|
||||
cancelCloneJob: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
import { api, type CloneJob } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
CLONE_JOB_EVENT,
|
||||
refreshCloneJobs,
|
||||
resetCloneJobsForTests,
|
||||
startClone,
|
||||
} from "../../lib/cloneJobs";
|
||||
import { beginUpload, reloadUploadsForTests } from "../../lib/uploadJobs";
|
||||
import { BackgroundJobsDock } from "../BackgroundJobsDock";
|
||||
|
||||
const base: CloneJob = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "running",
|
||||
url: "https://git.example/o/repo.git",
|
||||
name: "repo",
|
||||
parent: "/home/an",
|
||||
target: "/home/an/repo",
|
||||
laneId: null,
|
||||
position: null,
|
||||
phase: "receiving",
|
||||
label: null,
|
||||
percent: 40,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "https://git.example/o/repo.git", parent: "/home/an", name: "repo" },
|
||||
};
|
||||
const job = (over: Partial<CloneJob> = {}): CloneJob => ({ ...base, ...over });
|
||||
|
||||
const renderDock = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<BackgroundJobsDock />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetCloneJobsForTests();
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
reloadUploadsForTests();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [] });
|
||||
vi.mocked(api.lanes.dismissCloneJob).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe("cloneJobs store", () => {
|
||||
it("announces a background clone once, when it sees it finish", async () => {
|
||||
const onDone = vi.fn();
|
||||
const events: CloneJob[] = [];
|
||||
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).detail);
|
||||
window.addEventListener(CLONE_JOB_EVENT, listen);
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
|
||||
await startClone({ kind: "workspace", body: { url: base.url, parent: "/home/an" } }, { onDone });
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
|
||||
const done = job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } });
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [done] });
|
||||
await refreshCloneJobs();
|
||||
await refreshCloneJobs();
|
||||
expect(toast.success).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.success).mock.calls[0]![0]).toMatch(/Cloned repo into \/home\/an\/repo/);
|
||||
expect(events).toEqual([done]);
|
||||
expect(onDone).toHaveBeenCalledWith(done, undefined);
|
||||
window.removeEventListener(CLONE_JOB_EVENT, listen);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackgroundJobsDock", () => {
|
||||
it("shows a running clone's progress after a fresh mount, without a toast", async () => {
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(chip).toHaveTextContent("Cloning repo");
|
||||
expect(within(chip).getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an interrupted clone after F5 and retries it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "interrupted",
|
||||
error: { code: "EINTERRUPTED", message: "server restarted" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({
|
||||
job: job({ id: "j2", state: "queued", position: 1 }),
|
||||
});
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(chip).toHaveTextContent("AgentHub restarted while this clone was running.");
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await user.click(within(chip).getByRole("button", { name: "Retry" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("Waiting in the clone queue — #1")
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and retries with it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "failed",
|
||||
error: {
|
||||
code: "ECREDENTIALS",
|
||||
message: "login",
|
||||
auth: { protocol: "https", host: "git.example" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
|
||||
renderDock();
|
||||
const form = await screen.findByTestId("clone-job-login");
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("git.example needs a login");
|
||||
await user.type(within(form).getByLabelText("Username"), "ann");
|
||||
await user.type(within(form).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(form).getByRole("button", { name: "Retry with this login" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", {
|
||||
username: "ann",
|
||||
password: "tok",
|
||||
remember: true,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses a finished clone", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } })],
|
||||
});
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(within(chip).getByRole("button", { name: "Open workspace" })).toBeInTheDocument();
|
||||
await user.click(within(chip).getByTestId("clone-job-dismiss"));
|
||||
expect(api.lanes.dismissCloneJob).toHaveBeenCalledWith("j1");
|
||||
expect(screen.queryByTestId("clone-job")).toBeNull();
|
||||
});
|
||||
|
||||
it("stops a running clone on a second click, then lets it be retried", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
vi.mocked(api.lanes.cancelCloneJob).mockResolvedValue({
|
||||
job: job({ state: "cancelled", error: { code: "ECANCELLED", message: "cancelled" } }),
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
|
||||
renderDock();
|
||||
const stop = await screen.findByTestId("clone-job-stop");
|
||||
await user.click(stop);
|
||||
expect(api.lanes.cancelCloneJob).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("Click again to stop");
|
||||
await user.click(screen.getByTestId("clone-job-stop"));
|
||||
expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
|
||||
const chip = await screen.findByText("You stopped this clone.");
|
||||
expect(chip).toBeInTheDocument();
|
||||
expect(toast.info).toHaveBeenCalledWith("Stopped cloning repo.");
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when the server cannot stop a clone yet", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
vi.mocked(api.lanes.cancelCloneJob).mockRejectedValue(
|
||||
Object.assign(new Error("HTTP 404"), { status: 404 })
|
||||
);
|
||||
renderDock();
|
||||
await user.click(await screen.findByTestId("clone-job-stop"));
|
||||
await user.click(screen.getByTestId("clone-job-stop"));
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/cannot stop a clone yet/))
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an upload with Stop, which aborts it", async () => {
|
||||
renderDock();
|
||||
let up!: ReturnType<typeof beginUpload>;
|
||||
act(() => {
|
||||
up = beginUpload({ folder: "one/src", total: 3, bytes: 300 });
|
||||
up.update({ name: "a.txt", done: 1, sent: 150 });
|
||||
});
|
||||
const chip = await screen.findByTestId("fx-progress");
|
||||
expect(chip).toHaveTextContent("Uploading 2/3");
|
||||
expect(chip).toHaveTextContent("one/src · a.txt");
|
||||
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
|
||||
expect(up.signal.aborted).toBe(false);
|
||||
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
|
||||
expect(up.signal.aborted).toBe(true);
|
||||
act(() => up.end());
|
||||
expect(screen.queryByTestId("fx-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an upload a reload cut short until it is dismissed", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.uploads.inflight",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "up-old",
|
||||
folder: "one/src",
|
||||
name: "b.txt",
|
||||
done: 2,
|
||||
total: 5,
|
||||
sent: 10,
|
||||
bytes: 50,
|
||||
state: "running",
|
||||
beat: Date.now() - 60_000,
|
||||
},
|
||||
])
|
||||
);
|
||||
act(() => reloadUploadsForTests());
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("upload-job");
|
||||
expect(chip).toHaveTextContent(
|
||||
"The upload of 5 file(s) to one/src was interrupted by a page reload — 2 sent."
|
||||
);
|
||||
fireEvent.click(within(chip).getByRole("button", { name: "Dismiss" }));
|
||||
expect(screen.queryByTestId("upload-job")).toBeNull();
|
||||
expect(localStorage.getItem("agenthub.uploads.inflight")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses several chips to one pill", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [job(), job({ id: "j2", name: "other" })],
|
||||
});
|
||||
renderDock();
|
||||
await waitFor(() => expect(screen.getAllByTestId("clone-job")).toHaveLength(2));
|
||||
await user.click(screen.getByTestId("jobs-dock-collapse"));
|
||||
expect(screen.queryByTestId("clone-job")).toBeNull();
|
||||
expect(screen.getByTestId("jobs-dock-pill")).toHaveTextContent("2 background tasks");
|
||||
await user.click(screen.getByTestId("jobs-dock-pill"));
|
||||
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,11 @@
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
|
||||
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
|
||||
* Settings → Git; **Partial clone** makes it blobless). The clone is a
|
||||
* background job (`lib/cloneJobs.ts`): the modal closes once the server
|
||||
* accepted it, the app-wide dock shows its queue place, git's progress and
|
||||
* outcome, and the new workspace is selected when it ends while the user is
|
||||
* still on that page. There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
@@ -31,7 +34,7 @@ import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { api } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { startClone } from "../../lib/cloneJobs";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -119,8 +122,6 @@ export function AddLaneModal({
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [clonePartial, setClonePartial] = useState(false);
|
||||
/** The running clone's progress token (CloneProgress polls it). */
|
||||
const [cloneToken, setCloneToken] = useState<string | null>(null);
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
@@ -242,28 +243,46 @@ export function AddLaneModal({
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
const progressToken = newCloneToken();
|
||||
setCloneToken(progressToken);
|
||||
// The new workspace is selected when the clone ends only while the user
|
||||
// is still on the page they started it from; elsewhere the dock's
|
||||
// "Open workspace" does it.
|
||||
const from = window.location.pathname;
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
progressToken,
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onAdded(result.lane);
|
||||
await startClone(
|
||||
{
|
||||
kind: "workspace",
|
||||
body: {
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onDone: (job, lane) => {
|
||||
if (window.location.pathname !== from) return;
|
||||
if (lane) return onAdded(lane);
|
||||
const id = job.result?.laneId;
|
||||
if (id == null) return;
|
||||
api.lanes
|
||||
.get(id)
|
||||
.then((r) => onAdded(r.lane))
|
||||
.catch(() => undefined);
|
||||
},
|
||||
}
|
||||
);
|
||||
// Accepted: the dock carries the clone from here.
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -284,8 +303,6 @@ export function AddLaneModal({
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
} finally {
|
||||
setCloneToken(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -571,11 +588,7 @@ export function AddLaneModal({
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{cloneToken && (
|
||||
<div className="mt-2">
|
||||
<CloneProgress token={cloneToken} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -856,7 +869,7 @@ export function AddLaneModal({
|
||||
{done
|
||||
? t("addLaneSetupClose")
|
||||
: busy && mode === "clone"
|
||||
? t("clone.cloning")
|
||||
? t("cloneJobs.starting")
|
||||
: t("add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
* `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.
|
||||
* A clone runs in the background from the moment it starts: the dialog steps
|
||||
* aside for a small progress chip (`CloneProgress`; click to reopen), the
|
||||
* request keeps going, and its outcome arrives as a toast — a failure is kept
|
||||
* for the next open, and one asking for a login reopens the dialog with its
|
||||
* login row.
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -19,12 +19,9 @@ 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";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startClone } from "../../lib/cloneJobs";
|
||||
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -32,16 +29,11 @@ export function CloneHereDialog({
|
||||
open,
|
||||
parent,
|
||||
onClose,
|
||||
onCloned,
|
||||
onReopen,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The absolute folder the clone goes into. */
|
||||
parent: string;
|
||||
onClose: () => void;
|
||||
onCloned: (lane: Lane, path: string) => void;
|
||||
/** Show the dialog again from the background chip. */
|
||||
onReopen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -52,23 +44,10 @@ export function CloneHereDialog({
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
/** Where the running clone goes (the open folder may change meanwhile). */
|
||||
const [target, setTarget] = useState("");
|
||||
// Read by the request's own continuation and by the reset below.
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const busyRef = useRef(false);
|
||||
/** A clone failed while hidden: the next open shows that, not a blank form. */
|
||||
const keepRef = useRef(false);
|
||||
useDialog(open, panelRef, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || busyRef.current) return;
|
||||
if (keepRef.current) {
|
||||
keepRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
setUrl("");
|
||||
setName("");
|
||||
setLogin(null);
|
||||
@@ -77,70 +56,42 @@ export function CloneHereDialog({
|
||||
setError(null);
|
||||
}, [open, parent]);
|
||||
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
if (!open) return null;
|
||||
|
||||
if (!open) {
|
||||
if (!busy || !token) return null;
|
||||
return createPortal(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="fx-clone-chip"
|
||||
onClick={onReopen}
|
||||
title={into}
|
||||
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("clone.cloning")} {into.split("/").pop()}
|
||||
</span>
|
||||
</span>
|
||||
<CloneProgress token={token} />
|
||||
</button>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
|
||||
const submit = async () => {
|
||||
const u = url.trim();
|
||||
if (!u || busy) return;
|
||||
const progressToken = newCloneToken();
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setToken(progressToken);
|
||||
setTarget(into);
|
||||
setError(null);
|
||||
setSshRefused(false);
|
||||
// Runs in the background by default: the dialog steps aside for the
|
||||
// progress chip, and comes back only if the clone needs a login.
|
||||
onClose();
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
progressToken,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
onCloned(result.lane, result.path);
|
||||
// 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;
|
||||
let message: string;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
@@ -149,20 +100,11 @@ export function CloneHereDialog({
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
message = t("clone.sshRefused", { host });
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
} else {
|
||||
message = err instanceof Error ? err.message : String(err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setError(message);
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
if (!openRef.current) {
|
||||
keepRef.current = true;
|
||||
toast.error(message);
|
||||
// A login to type: show the form again with its login row.
|
||||
if (e.code === "ECREDENTIALS") onReopen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,7 +206,6 @@ export function CloneHereDialog({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{busy && token && <CloneProgress token={token} />}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}{" "}
|
||||
@@ -275,9 +216,10 @@ export function CloneHereDialog({
|
||||
)}
|
||||
</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">
|
||||
{busy ? t("clone.background") : t("fx.cancel")}
|
||||
{t("fx.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -285,7 +227,7 @@ export function CloneHereDialog({
|
||||
className="btn-primary text-xs"
|
||||
data-testid="fx-clone-submit"
|
||||
>
|
||||
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
|
||||
{busy ? t("cloneJobs.starting") : t("fx.cloneSubmit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,59 +2,35 @@
|
||||
* @file CloneProgress — the live line under a running clone: its place in the
|
||||
* server's clone queue while it waits (clones run a few at a time machine-wide,
|
||||
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
|
||||
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
|
||||
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
|
||||
* yet arrived, or an older server — just shows nothing yet.
|
||||
* with a bar. It renders the progress fields of a background clone job
|
||||
* (`lib/cloneJobs.ts`, polled by the store) — it fetches nothing itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type CloneProgressView } from "../../lib/api";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
export type CloneProgressFields = Pick<
|
||||
CloneJob,
|
||||
"state" | "position" | "phase" | "label" | "percent" | "detail"
|
||||
> & { running?: number; waiting?: number; limit?: number };
|
||||
|
||||
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
|
||||
export function newCloneToken(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function CloneProgress({ token }: { token: string }) {
|
||||
export function CloneProgress({ view }: { view: CloneProgressFields }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [view, setView] = useState<CloneProgressView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setView(null);
|
||||
// Never throws: progress is a courtesy, the clone's own answer decides.
|
||||
const poll = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.cloneProgress(token))
|
||||
.then((v) => {
|
||||
if (alive) setView(v);
|
||||
})
|
||||
.catch(() => {});
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
if (!view || view.state === "done" || view.state === "failed") return null;
|
||||
if (view.state !== "queued" && view.state !== "running") return null;
|
||||
|
||||
if (view.state === "queued") {
|
||||
return (
|
||||
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
|
||||
{t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})}
|
||||
{view.waiting !== undefined && view.running !== undefined && view.limit !== undefined
|
||||
? t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})
|
||||
: t("cloneJobs.queued", { position: view.position ?? 1 })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +49,9 @@ export function CloneProgress({ token }: { token: string }) {
|
||||
{view.phase ? ` ${percent}%` : ""}
|
||||
</span>
|
||||
{view.detail && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
|
||||
<span className="shrink-0 truncate font-mono text-[10px] text-fg-muted">
|
||||
{view.detail}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{view.phase && (
|
||||
|
||||
@@ -10,16 +10,19 @@
|
||||
* sent to Settings → Git first — and an HTTPS host with no saved login offers
|
||||
* the login row up front (optional: a public repo needs none). The identity is
|
||||
* read again when the window regains focus, so a key made in another tab
|
||||
* counts at once. The running clone shows its queue place, then git's
|
||||
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
|
||||
* counts at once. Each clone is a background job (`lib/cloneJobs.ts`): once
|
||||
* the server accepted it the app-wide dock shows its queue place, git's
|
||||
* progress and outcome (it survives leaving the page, F5 and a server
|
||||
* restart), and the folder list is re-read when it ends; **Partial clone**
|
||||
* sends `partial: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { api, type CloneJob, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CLONE_JOB_EVENT, startClone } from "../../lib/cloneJobs";
|
||||
|
||||
type Row = {
|
||||
url: string;
|
||||
@@ -27,8 +30,6 @@ type Row = {
|
||||
note?: string;
|
||||
/** The note points at Settings → Git (no key, or a key the host refused). */
|
||||
ssh?: boolean;
|
||||
/** While busy: the progress token CloneProgress polls. */
|
||||
token?: string;
|
||||
};
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -88,6 +89,23 @@ export function CloneSourcesForm({
|
||||
.catch(() => {}),
|
||||
[]
|
||||
);
|
||||
// A background clone into THIS workspace ended: its folder is now in the
|
||||
// list (the server added it), so read the list again.
|
||||
const onAddedRef = useRef(onAdded);
|
||||
onAddedRef.current = onAdded;
|
||||
useEffect(() => {
|
||||
const onJob = (e: Event) => {
|
||||
const job = (e as CustomEvent<CloneJob>).detail;
|
||||
if (job?.state !== "done" || job.kind !== "source" || job.laneId !== laneId) return;
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.sources(laneId))
|
||||
.then((view) => onAddedRef.current(view))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
window.addEventListener(CLONE_JOB_EVENT, onJob);
|
||||
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
|
||||
}, [laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadIdentity();
|
||||
const onFocus = () => void loadIdentity();
|
||||
@@ -121,25 +139,28 @@ export function CloneSourcesForm({
|
||||
for (const row of next) {
|
||||
if (row.state === "failed") continue;
|
||||
row.state = "busy";
|
||||
row.token = newCloneToken();
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
progressToken: row.token,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
await startClone({
|
||||
kind: "source",
|
||||
laneId,
|
||||
body: {
|
||||
url: row.url,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// Accepted: the dock shows its progress; the folder list is re-read
|
||||
// when it ends (the CLONE_JOB_EVENT listener above).
|
||||
row.state = "done";
|
||||
onAdded(view);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || row.url;
|
||||
@@ -159,7 +180,6 @@ export function CloneSourcesForm({
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
row.token = undefined;
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
@@ -301,10 +321,8 @@ export function CloneSourcesForm({
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.state === "busy" && r.token && (
|
||||
<div className="ml-[18px] mt-0.5">
|
||||
<CloneProgress token={r.token} />
|
||||
</div>
|
||||
{r.state === "done" && (
|
||||
<span className="ml-[18px] block text-fg-muted">{t("cloneJobs.started")}</span>
|
||||
)}
|
||||
{r.note && (
|
||||
<span className="ml-[18px] block text-status-danger">
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
* workspace's Files tab. Behaviour: delete → the account's Trash (no confirm:
|
||||
* restorable 7 days); Copy / Cut then Paste in the same or ANOTHER workspace
|
||||
* (the clipboard outlives the dialog); upload files or whole folders (picker
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch —
|
||||
* the progress lives in the app-wide dock (`lib/uploadJobs.ts`), so an
|
||||
* upload keeps going when the user opens Trash or another page, and can be
|
||||
* stopped there; New → Clone repository runs as a background clone job;
|
||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||
* itself, anything else as one zip — except an HTML file, which opens as the
|
||||
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
|
||||
@@ -99,6 +102,9 @@ import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { CloneHereDialog } from "./CloneHereDialog";
|
||||
import { CLONE_JOB_EVENT } from "../../lib/cloneJobs";
|
||||
import { beginUpload } from "../../lib/uploadJobs";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
@@ -327,13 +333,6 @@ export function FileBrowser({
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [ask, setAsk] = useState<Ask | null>(null);
|
||||
const [askAll, setAskAll] = useState(false);
|
||||
const [progress, setProgress] = useState<{
|
||||
name: string;
|
||||
done: number;
|
||||
total: number;
|
||||
sent: number;
|
||||
bytes: number;
|
||||
} | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const { state: auth } = useAuth();
|
||||
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
@@ -465,6 +464,17 @@ export function FileBrowser({
|
||||
loadLanes();
|
||||
onChanged?.();
|
||||
};
|
||||
// A background clone finished (the dock announced it): its folder and
|
||||
// workspace are new here, wherever it was started from.
|
||||
const changedRef = useRef(changed);
|
||||
changedRef.current = changed;
|
||||
useEffect(() => {
|
||||
const onJob = (e: Event) => {
|
||||
if ((e as CustomEvent<CloneJob>).detail?.state === "done") changedRef.current();
|
||||
};
|
||||
window.addEventListener(CLONE_JOB_EVENT, onJob);
|
||||
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
|
||||
}, []);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = listing?.entries ?? [];
|
||||
@@ -475,13 +485,28 @@ export function FileBrowser({
|
||||
}, [listing, showHidden, query]);
|
||||
const selectedEntries = entries.filter((e) => selected.has(e.path));
|
||||
|
||||
// An upload outlives this view (its progress is in the dock): once the view
|
||||
// is gone nobody can answer a name clash, so it keeps both copies.
|
||||
const mountedRef = useRef(true);
|
||||
const pendingAsk = useRef<Ask["resolve"] | null>(null);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
pendingAsk.current?.({ choice: "keep", all: true });
|
||||
pendingAsk.current = null;
|
||||
};
|
||||
}, []);
|
||||
/** A choice for taken names: Replace / Keep both / Skip (null = cancel). */
|
||||
const askConflict = (names: string[], many: boolean) =>
|
||||
new Promise<{ choice: FsConflict | null; all: boolean }>((resolve) => {
|
||||
if (!mountedRef.current) return resolve({ choice: "keep", all: true });
|
||||
pendingAsk.current = resolve;
|
||||
setAskAll(false);
|
||||
setAsk({ names, many, resolve });
|
||||
});
|
||||
const answer = (choice: FsConflict | null) => {
|
||||
pendingAsk.current = null;
|
||||
ask?.resolve({ choice, all: askAll });
|
||||
setAsk(null);
|
||||
};
|
||||
@@ -641,19 +666,29 @@ export function FileBrowser({
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
// The place is fixed now: the user may open another folder (or leave the
|
||||
// page) while this keeps sending; its progress lives in the dock.
|
||||
const place = target;
|
||||
const job = beginUpload({
|
||||
folder: `${placeName(place)}${dir ? `/${dir}` : ""}`,
|
||||
total: files.length,
|
||||
bytes,
|
||||
});
|
||||
const { signal } = job;
|
||||
let sent = 0;
|
||||
let done = 0;
|
||||
let saved = 0;
|
||||
let forAll: FsConflict | null = null;
|
||||
// false once the server says a batch cannot be written here (an old helper).
|
||||
let batchable = true;
|
||||
const show = (name: string, extra = 0) =>
|
||||
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
|
||||
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
|
||||
/** One file; false = the user cancelled. */
|
||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||
if (signal.aborted) return false;
|
||||
const dest = join(dir, rel);
|
||||
show(rel);
|
||||
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
|
||||
const send = (c: FsConflict) =>
|
||||
api.files.upload(place, dest, file, c, (n) => show(rel, n), signal);
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -664,7 +699,7 @@ export function FileBrowser({
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL") return false;
|
||||
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
}
|
||||
sent += file.size;
|
||||
@@ -684,9 +719,10 @@ export function FileBrowser({
|
||||
if (a.all && a.choice) forAll = a.choice;
|
||||
return a.choice;
|
||||
};
|
||||
if (signal.aborted) return "cancel";
|
||||
try {
|
||||
const c = await api.files.uploadCheck(
|
||||
target,
|
||||
place,
|
||||
dir,
|
||||
b.map((f) => f.rel)
|
||||
);
|
||||
@@ -697,7 +733,7 @@ export function FileBrowser({
|
||||
let policy = await choose(c.taken);
|
||||
if (!policy) return "cancel";
|
||||
const send = (p: FsConflict) =>
|
||||
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
|
||||
api.files.uploadBatch(place, dir, b, p, (n) => show(label, n), signal);
|
||||
const r = await send(policy).catch(async (e) => {
|
||||
// Taken meanwhile: ask about those, then once more.
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -708,7 +744,7 @@ export function FileBrowser({
|
||||
saved += r.saved;
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
if (code === "ECANCEL") return "cancel";
|
||||
if (code === "ECANCEL" || code === "EABORT") return "cancel";
|
||||
// Refused before a byte was written (a bad name, an old helper…):
|
||||
// each file then gets its own answer.
|
||||
if (code === "EUPGRADE") batchable = false;
|
||||
@@ -732,9 +768,10 @@ export function FileBrowser({
|
||||
}
|
||||
for (const f of singles) if (!go || !(await one(f))) break;
|
||||
} finally {
|
||||
setProgress(null);
|
||||
job.end();
|
||||
setBusy(false);
|
||||
if (saved) toast.success(t("fx.uploaded", { count: saved }));
|
||||
if (signal.aborted) toast.info(t("uploads.stopped", { count: saved, total: files.length }));
|
||||
else if (saved) toast.success(t("fx.uploaded", { count: saved }));
|
||||
changed();
|
||||
}
|
||||
};
|
||||
@@ -1009,18 +1046,7 @@ export function FileBrowser({
|
||||
/>
|
||||
)}
|
||||
{cloneParent && (
|
||||
<CloneHereDialog
|
||||
open={cloning}
|
||||
parent={cloneParent}
|
||||
onClose={() => setCloning(false)}
|
||||
onReopen={() => setCloning(true)}
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
<CloneHereDialog open={cloning} parent={cloneParent} onClose={() => setCloning(false)} />
|
||||
)}
|
||||
{/* ── Places ── */}
|
||||
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
||||
@@ -1668,25 +1694,6 @@ export function FileBrowser({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div
|
||||
data-testid="fx-progress"
|
||||
className="absolute bottom-12 right-4 w-72 rounded-lg border border-border bg-surface-1 p-3 shadow-xl"
|
||||
>
|
||||
<p className="truncate text-xs font-medium">
|
||||
{t("fx.uploading", { done: progress.done + 1, total: progress.total })}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-fg-muted">{progress.name}</p>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded bg-surface-3">
|
||||
<div
|
||||
className="h-full bg-accent-solid transition-[width]"
|
||||
style={{
|
||||
width: `${Math.min(100, (progress.sent / Math.max(1, progress.bytes)) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ctx.menu && (
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AddLaneModal } from "../AddLaneModal";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import { resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -26,6 +27,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneJobs: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
// The folder browser's Places list the user's workspaces.
|
||||
@@ -158,6 +160,8 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.clone).mockReset();
|
||||
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
|
||||
resetCloneJobsForTests();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
vi.mocked(api.lanes.pickPath).mockReset();
|
||||
// Provisioning finishes instantly by default - tests that care about the
|
||||
@@ -790,6 +794,54 @@ describe("AddLaneModal — Clone", () => {
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("hands a background clone to the dock and selects the workspace when it ends", async () => {
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const job = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "queued",
|
||||
url: "git@github.com:owner/repo.git",
|
||||
name: "repo",
|
||||
parent: "/home/an/code",
|
||||
target: "/home/an/code/repo",
|
||||
laneId: null,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "git@github.com:owner/repo.git", parent: "/home/an/code", name: "repo" },
|
||||
} as const;
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job } });
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [{ ...job, state: "done", result: { laneId: 5, path: "/home/an/code/repo" } }],
|
||||
});
|
||||
vi.mocked(api.lanes.get).mockResolvedValue({ lane: laneFixture({ id: 5 }) });
|
||||
const user = userEvent.setup();
|
||||
renderModal({ onAdded, onClose });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, "git@github.com:owner/repo.git");
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }))
|
||||
);
|
||||
// Accepted: the modal closes at once, before the clone is done.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(onAdded).not.toHaveBeenCalled();
|
||||
// The store's next read sees it done: the new workspace is selected.
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 5 })), {
|
||||
timeout: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends partial only when the box is ticked", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: laneFixture({ id: 7 }),
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
/**
|
||||
* @file Tests for CloneProgress: a queued clone shows its place in the
|
||||
* server's clone queue, a running one git's phase, percent and speed with a
|
||||
* bar, and nothing shows before the server knows the token. Also that
|
||||
* `newCloneToken` makes a token the server accepts.
|
||||
* server's clone queue (with the machine-wide counts when it has them), a
|
||||
* running one git's phase, percent and speed with a bar, and a finished one
|
||||
* nothing — all rendered from a clone job's fields, no fetching.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { CloneProgress, type CloneProgressFields } from "../CloneProgress";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneProgress: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type CloneProgressView } from "../../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "../CloneProgress";
|
||||
|
||||
const base: CloneProgressView = {
|
||||
const base: CloneProgressFields = {
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 2,
|
||||
waiting: 1,
|
||||
limit: 2,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
@@ -30,50 +21,44 @@ const base: CloneProgressView = {
|
||||
};
|
||||
|
||||
describe("CloneProgress", () => {
|
||||
// Braces: a function returned from beforeEach is run as its teardown.
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockReset();
|
||||
it("shows the clone's place in the queue while it waits", () => {
|
||||
render(<CloneProgress view={{ ...base, state: "queued", position: 2 }} />);
|
||||
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #2"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the clone's place in the queue while it waits", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
state: "queued",
|
||||
position: 1,
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
|
||||
it("adds the machine-wide counts when the view carries them", () => {
|
||||
render(
|
||||
<CloneProgress
|
||||
view={{ ...base, state: "queued", position: 1, waiting: 1, running: 2, limit: 2 }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows git's phase, percent and speed with a bar while it runs", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
phase: "receiving",
|
||||
label: "Receiving objects",
|
||||
percent: 45,
|
||||
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
const line = await screen.findByTestId("clone-progress");
|
||||
it("shows git's phase, percent and speed with a bar while it runs", () => {
|
||||
render(
|
||||
<CloneProgress
|
||||
view={{
|
||||
...base,
|
||||
phase: "receiving",
|
||||
label: "Receiving objects",
|
||||
percent: 45,
|
||||
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const line = screen.getByTestId("clone-progress");
|
||||
expect(line).toHaveTextContent("Downloading 45%");
|
||||
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
});
|
||||
|
||||
it("shows nothing while the server does not know the token yet", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
|
||||
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
|
||||
);
|
||||
render(<CloneProgress token="t" />);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
it("shows nothing once the clone finished", () => {
|
||||
render(<CloneProgress view={{ ...base, state: "done" }} />);
|
||||
expect(screen.queryByTestId("clone-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("makes tokens the server accepts", () => {
|
||||
const a = newCloneToken();
|
||||
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
|
||||
expect(newCloneToken()).not.toBe(a);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,17 +14,23 @@ import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||
api: { lanes: { cloneFolder: vi.fn(), sources: vi.fn(), cloneJobs: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
||||
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneFolder).mockReset();
|
||||
// A finished clone (an older server's answer, or a job the store saw end)
|
||||
// makes the form read the folder list again.
|
||||
vi.mocked(api.lanes.sources).mockReset().mockResolvedValue(view);
|
||||
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
|
||||
resetCloneJobsForTests();
|
||||
// Unknown identity: nothing is checked up front.
|
||||
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
|
||||
});
|
||||
@@ -49,7 +55,7 @@ describe("CloneSourcesForm", () => {
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
||||
expect(onAdded).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
||||
// Only what is left stays in the box.
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
||||
@@ -60,13 +66,52 @@ describe("CloneSourcesForm", () => {
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("hands a background clone to the dock and re-reads the folders when it ends", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdded = vi.fn();
|
||||
const job = {
|
||||
id: "s1",
|
||||
kind: "source",
|
||||
state: "queued",
|
||||
url: "https://h/a.git",
|
||||
name: "a",
|
||||
parent: "/",
|
||||
target: "/a",
|
||||
laneId: 3,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "https://h/a.git", parent: "/", name: "a" },
|
||||
} as const;
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue({ job: { ...job } });
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
||||
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/a.git");
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
expect(await screen.findByText(/progress is in the corner/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
expect(onAdded).not.toHaveBeenCalled();
|
||||
// Another workspace's clone is not ours.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, laneId: 4, state: "done" } })
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.sources).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("asks for partial clones when the box is ticked", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||
@@ -76,7 +121,7 @@ describe("CloneSourcesForm", () => {
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
url: "https://h/big.git",
|
||||
partial: true,
|
||||
})
|
||||
@@ -112,9 +157,11 @@ describe("CloneSourcesForm", () => {
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
url: "https://h.test/a.git",
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
});
|
||||
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,15 +11,18 @@
|
||||
* shows a Trash that could not be read as an error, never as empty; nests a
|
||||
* user's workspaces under their home in the sidebar (under a fold-only row
|
||||
* when the viewer may not open that home); opens an HTML file as the page
|
||||
* instead of downloading it.
|
||||
* instead of downloading it; New → Clone repository starts a background
|
||||
* clone (`background: true`), keeps a pre-start refusal in the form, and a
|
||||
* finished clone (the `agenthub:clone-job` event) re-reads the folder.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileExplorer } from "../FileExplorer";
|
||||
import { api, type FsListing } from "../../../lib/api";
|
||||
import { api, type CloneJob, type FsListing } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
@@ -32,7 +35,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneProgress: vi.fn(),
|
||||
cloneJobs: vi.fn(async () => ({ jobs: [] })),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
@@ -75,9 +78,30 @@ const src: FsListing = {
|
||||
truncated: false,
|
||||
entries: [{ name: "x.js", path: "src/x.js", type: "file", size: 2, mtime: 0 }],
|
||||
};
|
||||
const job: CloneJob = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "queued",
|
||||
url: "git@host:o/repo.git",
|
||||
name: "repo",
|
||||
parent: "/w/one",
|
||||
target: "/w/one/repo",
|
||||
laneId: null,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: { laneId: 9, path: "/w/one/repo" },
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "git@host:o/repo.git", parent: "/w/one", name: "repo" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetCloneJobsForTests();
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
@@ -230,7 +254,8 @@ describe("FileExplorer", () => {
|
||||
"a.txt",
|
||||
file,
|
||||
"keep",
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -251,7 +276,8 @@ describe("FileExplorer", () => {
|
||||
"",
|
||||
files.map((file) => ({ file, rel: file.name })),
|
||||
"replace",
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||
@@ -613,12 +639,8 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
return screen.getByTestId("fx-clone-dialog");
|
||||
};
|
||||
|
||||
it("clones into the open folder of the workspace and says where", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
|
||||
created: true,
|
||||
path: "/w/one/src/repo",
|
||||
} as never);
|
||||
it("clones into the open folder of the workspace in the background", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job, state: "queued" } } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
fireEvent.doubleClick(await screen.findByText("src"));
|
||||
@@ -632,26 +654,28 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
url: "git@host:o/repo.git",
|
||||
parent: "/w/one/src",
|
||||
name: undefined,
|
||||
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
|
||||
background: true,
|
||||
})
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
// Accepted: the dialog steps aside, the app-wide dock takes over.
|
||||
await waitFor(() => expect(screen.queryByTestId("fx-clone-dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("shows progress and lets the page go on while the clone runs", async () => {
|
||||
let finish: (v: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 1,
|
||||
waiting: 0,
|
||||
limit: 2,
|
||||
phase: "receiving",
|
||||
label: null,
|
||||
percent: 45,
|
||||
detail: "450/1000",
|
||||
it("re-reads the folder and workspaces when a background clone ends", async () => {
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const lists = vi.mocked(api.files.list).mock.calls.length;
|
||||
const laneReads = vi.mocked(api.lanes.list).mock.calls.length;
|
||||
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
|
||||
await waitFor(() => expect(vi.mocked(api.files.list).mock.calls.length).toBeGreaterThan(lists));
|
||||
expect(vi.mocked(api.lanes.list).mock.calls.length).toBeGreaterThan(laneReads);
|
||||
});
|
||||
|
||||
it("treats an older server's synchronous answer as a finished clone", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||
created: true,
|
||||
path: "/w/one/repo",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
@@ -659,45 +683,8 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
// In the background straight away: the chip carries the progress.
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
const chip = await screen.findByTestId("fx-clone-chip");
|
||||
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
|
||||
expect(await within(chip).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
await user.click(chip);
|
||||
const back = screen.getByTestId("fx-clone-dialog");
|
||||
expect(await within(back).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
await user.click(within(back).getByRole("button", { name: "Run in background" }));
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
finish({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||
created: true,
|
||||
path: "/w/one/repo",
|
||||
});
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("toasts a failure that lands while hidden, and reopens the form for a login", async () => {
|
||||
let fail: (e: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
fail(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
// A login to type reopens the form by itself, its row ready.
|
||||
const again = await screen.findByTestId("fx-clone-dialog");
|
||||
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
|
||||
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
|
||||
"https://git.example/o/r.git"
|
||||
);
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||
@@ -705,26 +692,24 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
lane: { id: 9, title: "r", cwd: "/w/one/r" },
|
||||
created: true,
|
||||
path: "/w/one/r",
|
||||
} as never);
|
||||
.mockResolvedValueOnce({ job } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
const reopened = await screen.findByTestId("fx-clone-dialog");
|
||||
const login = await within(reopened).findByTestId("fx-clone-login");
|
||||
// A refusal before the clone starts stays in the form, with a login row.
|
||||
const login = await within(dialog).findByTestId("fx-clone-login");
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent("git.example needs a login");
|
||||
await user.type(within(login).getByLabelText("Username"), "ann");
|
||||
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(reopened).getByTestId("fx-clone-submit"));
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
parent: "/w/one",
|
||||
background: true,
|
||||
credentials: { username: "ann", password: "tok", remember: true },
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user