/** * @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, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Link, 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" && ( // A router link: a plain reloaded the whole app. {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.waiting && !stopping && (

{t("uploads.waiting")}

)}

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

); } export function BackgroundJobsDock() { const { t } = useTranslation("lanes"); const jobs = useCloneJobs(); const uploads = useUploadJobs(); const [collapsed, setCollapsed] = useState(false); const dockRef = useRef(null); useEffect(() => ensureCloneJobs(), []); const count = jobs.length + uploads.length; // Toasts stack ABOVE the dock (Toaster reads --jobs-dock-h): they used to // sit on the same corner and cover a chip's × / Retry / Stop. useEffect(() => { const root = document.documentElement; const el = dockRef.current; if (!count || !el) { root.style.removeProperty("--jobs-dock-h"); return; } const set = () => root.style.setProperty("--jobs-dock-h", `${el.offsetHeight + 8}px`); set(); const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set); ro?.observe(el); return () => { ro?.disconnect(); root.style.removeProperty("--jobs-dock-h"); }; }, [count, collapsed]); 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) => ( ))} )}
); }