2b26fe1efa
A failed or stopped clone also raised a toast on the same corner, a layer above the dock, so it covered the chip's x / Retry / Stop and repeated the same long message; the chip now says it alone, and every toast stacks above the dock (--jobs-dock-h). A URL with no space to break at (user@host:/PATH) wraps inside the chip and inside a toast instead of running off the screen. Open Settings -> Git is a router link; a plain <a href> reloaded the app.
381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
/**
|
||
* @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, 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 (
|
||
<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] [overflow-wrap:anywhere] ${
|
||
job.state === "cancelled" ? "text-fg-secondary" : "text-status-danger"
|
||
}`}
|
||
>
|
||
{cloneJobError(job)}{" "}
|
||
{code === "ESSHAUTH" && (
|
||
// A router link: a plain <a href> reloaded the whole app.
|
||
<Link to="/settings/git#git-identity" className="underline">
|
||
{t("clone.sshSettings")}
|
||
</Link>
|
||
)}
|
||
</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 [overflow-wrap:anywhere]">
|
||
{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 [overflow-wrap:anywhere]">
|
||
{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);
|
||
const dockRef = useRef<HTMLDivElement>(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 (
|
||
<div
|
||
ref={dockRef}
|
||
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>
|
||
);
|
||
}
|