From 57cbab916a81a317dda8701e6520dc8f804750f4 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 2 Nov 2026 10:43:57 +0700 Subject: [PATCH] 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. --- client/src/components/BackgroundJobsDock.tsx | 359 ++++++++++++++++++ client/src/components/Layout.tsx | 3 + .../__tests__/BackgroundJobsDock.test.tsx | 290 ++++++++++++++ client/src/components/lanes/AddLaneModal.tsx | 81 ++-- .../src/components/lanes/CloneHereDialog.tsx | 130 ++----- client/src/components/lanes/CloneProgress.tsx | 62 +-- .../src/components/lanes/CloneSourcesForm.tsx | 72 ++-- client/src/components/lanes/FileExplorer.tsx | 103 ++--- .../lanes/__tests__/AddLaneModal.test.tsx | 52 +++ .../lanes/__tests__/CloneProgress.test.tsx | 81 ++-- .../lanes/__tests__/CloneSourcesForm.test.tsx | 59 ++- .../lanes/__tests__/FileExplorer.test.tsx | 131 +++---- client/src/i18n/locales/en/lanes.json | 37 +- client/src/i18n/locales/vi/lanes.json | 37 +- client/src/lib/api.ts | 97 ++++- client/src/lib/cloneJobs.ts | 318 ++++++++++++++++ client/src/lib/uploadJobs.ts | 167 ++++++++ docs/API.md | 18 +- 18 files changed, 1708 insertions(+), 389 deletions(-) create mode 100644 client/src/components/BackgroundJobsDock.tsx create mode 100644 client/src/components/__tests__/BackgroundJobsDock.test.tsx create mode 100644 client/src/lib/cloneJobs.ts create mode 100644 client/src/lib/uploadJobs.ts diff --git a/client/src/components/BackgroundJobsDock.tsx b/client/src/components/BackgroundJobsDock.tsx new file mode 100644 index 00000000..bf5a89be --- /dev/null +++ b/client/src/components/BackgroundJobsDock.tsx @@ -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ĩ + */ + +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 ( + + ); +} + +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(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 ( +
+
+ + {title} + {active ? ( + + + + ) : ( + void dismissCloneJob(job.id)} + testId="clone-job-dismiss" + > + + + )} +
+ {active && isArmed("stop") && ( +

{t("cloneJobs.stopConfirm")}

+ )} + {active && } + {job.state === "done" && ( +
+ + {job.result?.path || job.target} + + {laneId !== null && ( + + )} +
+ )} + {!active && job.state !== "done" && ( + <> +

+ {cloneJobError(job)}{" "} + {code === "ESSHAUTH" && ( + + {t("clone.sshSettings")} + + )} +

+ {code === "ECREDENTIALS" && job.state === "failed" ? ( +
{ + e.preventDefault(); + void retry(true); + }} + > + setLogin({ ...login, username: e.target.value })} + className="input w-full text-xs" + /> + setLogin({ ...login, token: e.target.value })} + className="input w-full text-xs" + /> +
+ + +
+
+ ) : ( + code !== "ESSHAUTH" && ( +
+ +
+ ) + )} + {error && ( +

+ {error} +

+ )} + + )} +
+ ); +} + +function UploadChip({ job }: { job: UploadJob }) { + const { t } = useTranslation("lanes"); + const { arm, isArmed, disarm } = useArmed(); + if (job.state === "interrupted") { + return ( +
+
+ +

+ {t("uploads.interrupted", { + total: job.total, + folder: job.folder, + done: job.done, + })} +

+ dismissUpload(job.id)}> + + +
+
+ ); + } + const stopping = job.state === "stopping"; + return ( +
+
+ + + {stopping + ? t("uploads.stopping") + : t("fx.uploading", { done: Math.min(job.done + 1, job.total), total: job.total })} + + {!stopping && ( + { + if (!isArmed("stop")) return arm("stop"); + disarm(); + stopUpload(job.id); + }} + > + + + )} +
+ {isArmed("stop") && !stopping && ( +

{t("uploads.stopConfirm")}

+ )} +

+ {job.folder} + {job.name ? ` · ${job.name}` : ""} +

+
+
+
+
+ ); +} + +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 ( +
+ {count > 1 && collapsed ? ( + + ) : ( + <> + {count > 1 && ( + + )} + {uploads.map((u) => ( + + ))} + {jobs.map((j) => ( + + ))} + + )} +
+ ); +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 31e0f668..d8ba3bdd 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -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")} + {onboarding.loaded && !onboarding.onboardedAt && ( diff --git a/client/src/components/__tests__/BackgroundJobsDock.test.tsx b/client/src/components/__tests__/BackgroundJobsDock.test.tsx new file mode 100644 index 00000000..ebf41fad --- /dev/null +++ b/client/src/components/__tests__/BackgroundJobsDock.test.tsx @@ -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ĩ + */ + +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 => ({ ...base, ...over }); + +const renderDock = () => + render( + + + + ); + +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).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; + 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); + }); +}); diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index 30e3b3d4..bc847e65 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -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(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")} - {cloneToken && ( -
- -
- )} +

{t("cloneJobs.backgroundHint")}

)} @@ -856,7 +869,7 @@ export function AddLaneModal({ {done ? t("addLaneSetupClose") : busy && mode === "clone" - ? t("clone.cloning") + ? t("cloneJobs.starting") : t("add")} diff --git a/client/src/components/lanes/CloneHereDialog.tsx b/client/src/components/lanes/CloneHereDialog.tsx index e5e56989..11da489a 100644 --- a/client/src/components/lanes/CloneHereDialog.tsx +++ b/client/src/components/lanes/CloneHereDialog.tsx @@ -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ĩ */ @@ -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(null); @@ -52,23 +44,10 @@ export function CloneHereDialog({ const [sshRefused, setSshRefused] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [token, setToken] = useState(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( - , - 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({ )} - {busy && token && } {error && (

{error}{" "} @@ -275,9 +216,10 @@ export function CloneHereDialog({ )}

)} +

{t("cloneJobs.backgroundHint")}

diff --git a/client/src/components/lanes/CloneProgress.tsx b/client/src/components/lanes/CloneProgress.tsx index e7cc594a..1ea008ad 100644 --- a/client/src/components/lanes/CloneProgress.tsx +++ b/client/src/components/lanes/CloneProgress.tsx @@ -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ĩ */ -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(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 (

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

); } @@ -73,7 +49,9 @@ export function CloneProgress({ token }: { token: string }) { {view.phase ? ` ${percent}%` : ""} {view.detail && ( - {view.detail} + + {view.detail} + )}

{view.phase && ( diff --git a/client/src/components/lanes/CloneSourcesForm.tsx b/client/src/components/lanes/CloneSourcesForm.tsx index dd01ad7f..d1c20d2b 100644 --- a/client/src/components/lanes/CloneSourcesForm.tsx +++ b/client/src/components/lanes/CloneSourcesForm.tsx @@ -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ĩ */ -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).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} - {r.state === "busy" && r.token && ( -
- -
+ {r.state === "done" && ( + {t("cloneJobs.started")} )} {r.note && ( diff --git a/client/src/components/lanes/FileExplorer.tsx b/client/src/components/lanes/FileExplorer.tsx index 35bf3f7e..a8e1d07a 100644 --- a/client/src/components/lanes/FileExplorer.tsx +++ b/client/src/components/lanes/FileExplorer.tsx @@ -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(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([]); 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).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(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 && ( - 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(); - }} - /> + setCloning(false)} /> )} {/* ── Places ── */}