feat(sources): real-time file progress for sync and upload — background upload with success toast
This commit is contained in:
+275
-41
@@ -34,20 +34,38 @@
|
||||
* shape-aware like the tuning forms) and keeps the input — the
|
||||
* instruction survives. 409/422 details are fixed generic strings
|
||||
* (credential safety — the URL is never echoed).
|
||||
* • upload — #archive-upload-form submit (phase 49) → POST
|
||||
* • upload — #archive-upload-form submit (phase 49, reworked to the
|
||||
* phase-64 202 contract in task 05 — the phase-49 synchronous
|
||||
* 200 paragraph is superseded): POST
|
||||
* /api/git-sources/upload with a FormData file (NO manual
|
||||
* Content-Type — the browser sets the multipart boundary). The
|
||||
* SAME §7.4 never-stale lifecycle: the button disables +
|
||||
* relabels "Uploading…" while the request is out and is restored
|
||||
* on success AND failure. 200 clears the file input, shows the
|
||||
* sync-style count line ("2 added · 1 pruned" — fmtUploadResult,
|
||||
* sources.js's fmtSyncResult convention) in the role=status
|
||||
* result line, announces "Archive uploaded: …" and reloads the
|
||||
* list (the new/updated row lands with the Local badge; a
|
||||
* re-upload simply refreshes the row — no duplicate). Non-2xx
|
||||
* inlines the server detail (422 format/name/traversal, 413
|
||||
* size, 409 busy — the messages are already user-safe) and KEEPS
|
||||
* the file selection — the fix is one re-pick, not a re-type.
|
||||
* §7.4 never-stale lifecycle keeps its shape — the button
|
||||
* disables + relabels "Uploading…" while the request is out —
|
||||
* but the transfer is now short: the 202 arrives the moment the
|
||||
* archive is safely on disk (A1). 202 → the page-local
|
||||
* "Successfully uploaded — <file>" toast fires (showUploadToast,
|
||||
* the phase-55 share-toast pattern; A2: safe to navigate away),
|
||||
* the file input clears, and the button hands over to the scan —
|
||||
* the processing state ("Processing…", disabled, title cleared)
|
||||
* plus startUploadPolling(): a 2 s poll of
|
||||
* GET /api/git-sources/upload/status renders the live
|
||||
* "Processing… <file> (n/m)" label (A4 — bare during unpack; the
|
||||
* full path rides the button title) and settles it: success →
|
||||
* the sync-style count line (fmtUploadResult, the role=status
|
||||
* result line) + the "Archive uploaded: …" announce +
|
||||
* loadSources (the new/updated row lands with the Local badge;
|
||||
* a re-upload refreshes the row — no duplicate; NO second toast
|
||||
* — A2); failure → the sanitized server error in the role=alert
|
||||
* banner + loadSources, the file selection KEPT for a one-click
|
||||
* re-upload. 409 (an upload is already in progress) raises NO
|
||||
* error banner — it re-attaches to the in-flight run (processing
|
||||
* state + poll, never stale). Other non-2xx (422 format/name,
|
||||
* 413 cap, 5xx) keep the phase-49 error banner + the kept file
|
||||
* selection. The submit finally restores the button ONLY when no
|
||||
* poll is active (§7.4). Boot re-attach (initUploadStatus, admin
|
||||
* branch): a running scan re-enters the processing state + poll
|
||||
* (a reload mid-scan re-attaches — no second upload), a terminal
|
||||
* run re-renders its result line / error banner.
|
||||
* • remove — a row's Remove button asks window.confirm first
|
||||
* (removal prunes the documents only on the NEXT sync — the
|
||||
* confirm says so). Cancel → nothing; ok → the row button
|
||||
@@ -64,8 +82,8 @@
|
||||
* removing a source does NOT clone, import, or prune — the sync
|
||||
* service (server-side) performs that; the page's hint box says so.
|
||||
* The phase-49 upload is the exception: it unpacks and scans the
|
||||
* single source in place, and its response counts render as the
|
||||
* result line.
|
||||
* single source in place (the phase-64 background task — 202 +
|
||||
* status endpoint), and its counts render as the result line.
|
||||
*
|
||||
* The shared header module loads through this script's own relative
|
||||
* import ("./header.js") — a hoisted import evaluated before this body
|
||||
@@ -354,22 +372,42 @@ wireAddForm({
|
||||
idleLabel: "Add source",
|
||||
});
|
||||
|
||||
/* ---------- upload (POST /api/git-sources/upload) — phase 49 -------
|
||||
* The archive upload form: the file input's selection is posted as
|
||||
* FormData (the browser sets the multipart boundary — no manual
|
||||
* Content-Type). §7.4 never-stale: "Uploading…" while in flight,
|
||||
* restored in the finally block on success AND failure. 200 → the
|
||||
* input clears, the sync-style counts land in the role=status result
|
||||
* line, the announcer confirms, and loadSources() re-renders the row
|
||||
* (Local badge; a re-upload refreshes the existing row — no
|
||||
* duplicate). Non-2xx → the server detail inline (role=alert; 422
|
||||
* format/name/traversal, 413 size, 409 busy — user-safe as-is) with
|
||||
* the file selection KEPT; network failure → the fixed line. */
|
||||
/* ---------- upload (POST /api/git-sources/upload) — phase 64 (task 05) -------
|
||||
* The archive upload form follows the phase-64 202 contract (A1):
|
||||
* the file input's selection is posted as FormData (the browser sets
|
||||
* the multipart boundary — no manual Content-Type), and the 202
|
||||
* answers the moment the archive is safely on disk — the "Uploading…"
|
||||
* label covers only that short receive. Then the button HANDS OVER to
|
||||
* the scan: 202 → the page-local "Successfully uploaded — <file>"
|
||||
* toast (showUploadToast — A2, safe to navigate away), the file input
|
||||
* clears, and the processing state ("Processing…", disabled, title
|
||||
* cleared) + startUploadPolling() own it — a 2 s poll of
|
||||
* GET /api/git-sources/upload/status renders the live "Processing…
|
||||
* <file> (n/m)" label (A4 — bare during unpack; the full path rides
|
||||
* the button title) and settles it: success → the sync-style count
|
||||
* line (fmtUploadResult) in the role=status result line + the
|
||||
* "Archive uploaded: …" announce + loadSources (NO second toast — it
|
||||
* already fired at the 202, A2); failure → the sanitized server
|
||||
* error in the role=alert banner + loadSources, the file selection
|
||||
* KEPT for a one-click re-upload. 409 (an upload is already in
|
||||
* progress) raises NO error banner — it re-attaches to the in-flight
|
||||
* run (processing state + poll, never stale); the phase-49 "server
|
||||
* detail inline for 409" branch is superseded. Other non-2xx (422
|
||||
* format/name, 413 cap, 5xx) keep the phase-49 error banner + the
|
||||
* kept file selection; a network failure keeps the fixed line. The
|
||||
* submit finally restores the button ONLY when no poll is active
|
||||
* (PLAN §7.4 — while startUploadPolling owns the button it stays
|
||||
* disabled / "Processing…"). Boot re-attach (initUploadStatus, the
|
||||
* admin branch): a running scan re-enters the processing state + poll
|
||||
* (no second upload, no error); a terminal run re-renders its result
|
||||
* line (success) or error banner (failed); idle does nothing.
|
||||
* (The phase-49 synchronous 200 paragraph is superseded by phase 64.) */
|
||||
|
||||
/* The success line's text — the sync-result shape (sources.js's
|
||||
fmtSyncResult convention): "N added" always leads, then updated /
|
||||
unchanged / pruned — zero parts omitted (unchanged is shown
|
||||
when nothing was added or updated). */
|
||||
when nothing was added or updated). Reads exactly the keys the
|
||||
upload status's detail carries (task 03's UploadOut-shaped dict). */
|
||||
function fmtUploadResult(detail) {
|
||||
const d = detail || {};
|
||||
const added = d.added || 0;
|
||||
@@ -383,6 +421,183 @@ function fmtUploadResult(detail) {
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/* Upload-success toast (phase 64 task 05, A2 — owner-locked): the
|
||||
* "successfully uploaded" confirmation, the phase-55 share-toast
|
||||
* pattern (frontend/assets/app.js) made page-local. A SINGLE node —
|
||||
* lazy-created on the first 202 and reused (toasts never stack): a
|
||||
* new toast replaces a pending one (clear the prior dismiss timer,
|
||||
* re-run the entry). role="status" aria-live="polite" — on THIS page
|
||||
* the toast IS the a11y announcer for the 202 (there is no other
|
||||
* live-region line for it). SUCCESS-ONLY (A2): failures are the
|
||||
* #archive-upload-error banner, never a toast. The .toast CSS ships
|
||||
* as-is (styles.css, phase 55). */
|
||||
const UPLOAD_TOAST_MS = 5000; // ~5 s auto-dismiss (A2)
|
||||
let uploadToastEl = null; // the single toast node — lazy-created, reused
|
||||
let uploadToastTimer = 0; // the pending auto-dismiss (replaced by a new toast)
|
||||
|
||||
function showUploadToast(message) {
|
||||
if (!uploadToastEl) {
|
||||
uploadToastEl = document.createElement("div");
|
||||
uploadToastEl.className = "toast";
|
||||
uploadToastEl.setAttribute("role", "status");
|
||||
uploadToastEl.setAttribute("aria-live", "polite");
|
||||
document.body.appendChild(uploadToastEl);
|
||||
}
|
||||
uploadToastEl.textContent = message; // XSS-safe text assignment
|
||||
// Re-trigger the entry even when a toast is already up (a second
|
||||
// upload accepted while the first toast is showing): clear the
|
||||
// pending dismiss, drop the visible state, force a reflow
|
||||
// (restarts the CSS transition), then show again.
|
||||
clearTimeout(uploadToastTimer);
|
||||
uploadToastEl.classList.remove("is-visible");
|
||||
void uploadToastEl.offsetWidth; // force reflow — the entry transition restarts
|
||||
uploadToastEl.classList.add("is-visible");
|
||||
uploadToastTimer = setTimeout(() => {
|
||||
uploadToastEl.classList.remove("is-visible"); // auto-dismiss ~5 s
|
||||
}, UPLOAD_TOAST_MS);
|
||||
}
|
||||
|
||||
/* The scan poll (phase 64 task 05): a 2 s cadence — the SYNC_POLL_MS
|
||||
* house value. Single timer, one loop at a time (the guard makes a
|
||||
* double-start a no-op, and the submit finally reads this same
|
||||
* variable to know whether the poll OWNS the button). Each tick
|
||||
* fetches GET /api/git-sources/upload/status: running → the live
|
||||
* "Processing… <file> (n/m)" label (A4 — bare "Processing…" during
|
||||
* the unpack phase, before any file is indexed; the full untruncated
|
||||
* path rides the button title) + reschedule; success → stop + the
|
||||
* result line + the announcement + the row reload (NO toast — it
|
||||
* fired at the 202, A2); failed → stop + the sanitized server error
|
||||
* banner + the row reload (a post-swap failure keeps the row — the
|
||||
* list state may have changed), the file selection kept for a
|
||||
* one-click re-upload; idle → stop + the button restored
|
||||
* (defensive — a started run never returns to idle). A network blip
|
||||
* retries next tick. */
|
||||
const UPLOAD_POLL_MS = 2000; // the SYNC_POLL_MS house value
|
||||
let uploadPollTimer = null; // null = no poll active (the finally's guard)
|
||||
|
||||
function stopUploadPolling() {
|
||||
if (uploadPollTimer !== null) {
|
||||
clearTimeout(uploadPollTimer);
|
||||
uploadPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* The button's processing entry (the 202 + the 409 re-attach): from
|
||||
* here the poll OWNS it — disabled, "Processing…", title cleared (a
|
||||
* live file lands on it at the first tick). */
|
||||
function enterUploadProcessingState() {
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = "Processing…";
|
||||
uploadBtn.title = "";
|
||||
}
|
||||
|
||||
/* The idle restore (the poll's terminal branches + the submit
|
||||
* finally, which calls this ONLY when no poll is active — PLAN §7.4). */
|
||||
function restoreUploadButton() {
|
||||
uploadBtn.disabled = false; // never stale — success OR failure
|
||||
uploadBtn.textContent = "Upload & scan";
|
||||
uploadBtn.removeAttribute("title");
|
||||
}
|
||||
|
||||
function startUploadPolling() {
|
||||
if (uploadPollTimer !== null) return; // one poll loop at a time
|
||||
const tick = async () => {
|
||||
let status = null;
|
||||
try {
|
||||
const r = await fetch("/api/git-sources/upload/status");
|
||||
if (r.ok) status = await r.json();
|
||||
} catch { /* network blip — retry next tick */ }
|
||||
if (!status) {
|
||||
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
|
||||
return;
|
||||
}
|
||||
// running: the live file label (A4 — bare "Processing…" during
|
||||
// the unpack phase, before any file is indexed).
|
||||
if (status.state === "running") {
|
||||
uploadBtn.textContent =
|
||||
"Processing…" +
|
||||
(status.current_file ? ` ${status.current_file}` : "") +
|
||||
(status.files_total > 0 ? ` (${status.files_done}/${status.files_total})` : "");
|
||||
uploadBtn.title = status.current_file || ""; // full path on hover
|
||||
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
|
||||
return;
|
||||
}
|
||||
stopUploadPolling();
|
||||
if (status.state === "success") {
|
||||
// The scan finished: the result line (the existing helper reads
|
||||
// exactly these keys), the announcement, the row lands. NO toast
|
||||
// here — it already fired at the 202 (A2).
|
||||
const detail = status.detail || {};
|
||||
if (uploadResult) {
|
||||
uploadResult.textContent = fmtUploadResult(detail);
|
||||
uploadResult.hidden = false;
|
||||
}
|
||||
announce(`Archive uploaded: ${detail.source}.`);
|
||||
uploadFileInput.value = "";
|
||||
restoreUploadButton();
|
||||
loadSources(); // the row lands / refreshes
|
||||
return;
|
||||
}
|
||||
if (status.state === "failed") {
|
||||
// Sanitized server-side (task 03's _sanitize_error): the banner
|
||||
// is the failure UI (A2), the selection stays KEPT for a
|
||||
// one-click re-upload, and the list reloads (a post-swap failure
|
||||
// keeps the row — the list state may have changed).
|
||||
if (uploadError) {
|
||||
uploadError.textContent = status.error || "The upload scan failed.";
|
||||
uploadError.hidden = false;
|
||||
}
|
||||
restoreUploadButton();
|
||||
loadSources(); // the list state may have changed
|
||||
return;
|
||||
}
|
||||
// idle: defensive — a started run never returns to idle; just
|
||||
// settle the button (the poll stopped above).
|
||||
restoreUploadButton();
|
||||
};
|
||||
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
|
||||
}
|
||||
|
||||
/* Boot re-attach (phase 64 task 05, the admin branch only): fetch the
|
||||
* upload status ONCE — a running scan re-enters the processing state
|
||||
* + the poll (a reload mid-scan re-attaches instead of dead-ending —
|
||||
* no second upload, no error); a finished run re-renders its result
|
||||
* line ONLY (no announce, no toast — the toast fired at the 202, A2);
|
||||
* a failed run re-renders its error banner; idle does nothing (and a
|
||||
* blip is a no-op — the page boots honest either way). */
|
||||
async function initUploadStatus() {
|
||||
if (!uploadBtn) return;
|
||||
let status;
|
||||
try {
|
||||
const r = await fetch("/api/git-sources/upload/status");
|
||||
if (!r.ok) return;
|
||||
status = await r.json();
|
||||
} catch {
|
||||
/* network blip — boot without the re-attach */
|
||||
}
|
||||
if (!status) return;
|
||||
if (status.state === "running") {
|
||||
enterUploadProcessingState();
|
||||
startUploadPolling(); // the first tick carries the live file
|
||||
return;
|
||||
}
|
||||
if (status.state === "success") {
|
||||
// The last run's result line only — no announce, no toast (A2).
|
||||
if (uploadResult) {
|
||||
uploadResult.textContent = fmtUploadResult(status.detail);
|
||||
uploadResult.hidden = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (status.state === "failed") {
|
||||
if (uploadError) {
|
||||
uploadError.textContent = status.error || "The upload scan failed.";
|
||||
uploadError.hidden = false;
|
||||
}
|
||||
}
|
||||
// idle: nothing to re-attach.
|
||||
}
|
||||
|
||||
if (uploadFormEl && uploadFileInput && uploadBtn) {
|
||||
uploadFormEl.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -399,7 +614,7 @@ if (uploadFormEl && uploadFileInput && uploadBtn) {
|
||||
if (uploadError) uploadError.hidden = true;
|
||||
if (uploadResult) uploadResult.hidden = true; // a new attempt starts clean
|
||||
uploadBtn.disabled = true; // §7.4: one upload per click
|
||||
uploadBtn.textContent = "Uploading…";
|
||||
uploadBtn.textContent = "Uploading…"; // the transfer is now short — the 202
|
||||
try {
|
||||
// Multipart from the form itself (the file input's name is
|
||||
// "file") — the browser sets the boundary; NO manual
|
||||
@@ -408,23 +623,35 @@ if (uploadFormEl && uploadFileInput && uploadBtn) {
|
||||
method: "POST",
|
||||
body: new FormData(uploadFormEl),
|
||||
});
|
||||
if (r.ok) {
|
||||
let data = {};
|
||||
if (r.status === 202) {
|
||||
// The archive is safely on disk (A1) — the "successfully
|
||||
// uploaded" moment: the toast fires NOW (A2), the file input
|
||||
// clears, and the scan's poll takes over the button. The 202
|
||||
// body (UploadAccepted) carries the safe source name; a body
|
||||
// parse failure degrades to the picked file's name.
|
||||
let name = file.name;
|
||||
try {
|
||||
data = await r.json();
|
||||
const data = await r.json();
|
||||
if (data && typeof data.name === "string" && data.name) name = data.name;
|
||||
} catch {
|
||||
/* the body is advisory — the counts line degrades gracefully */
|
||||
/* body parse failure — the picked file's name degrades fine */
|
||||
}
|
||||
uploadFileInput.value = ""; // 200: the archive is unpacked + scanned
|
||||
if (uploadResult) {
|
||||
uploadResult.textContent = fmtUploadResult(data);
|
||||
uploadResult.hidden = false;
|
||||
}
|
||||
announce(`Archive uploaded: ${data.source || file.name}.`);
|
||||
await loadSources(); // the new/updated row lands (Local badge)
|
||||
showUploadToast(`Successfully uploaded — ${name}`);
|
||||
uploadFileInput.value = ""; // 202: the archive is on the server
|
||||
enterUploadProcessingState();
|
||||
startUploadPolling();
|
||||
return;
|
||||
}
|
||||
// 422 (format/name/traversal), 413 (size), 409 (busy): the server
|
||||
// 409 (an upload is already in progress): NO error banner —
|
||||
// re-attach to the in-flight run (never stale): the processing
|
||||
// state + the poll track it to the terminal. The phase-49
|
||||
// "server detail inline" branch does not apply to 409 anymore.
|
||||
if (r.status === 409) {
|
||||
enterUploadProcessingState();
|
||||
startUploadPolling();
|
||||
return;
|
||||
}
|
||||
// Other non-2xx (422 format/name, 413 cap, 5xx): the server
|
||||
// detail inline, the file selection KEPT — the fix is one
|
||||
// re-pick, not a re-type.
|
||||
if (uploadError) {
|
||||
@@ -437,8 +664,11 @@ if (uploadFormEl && uploadFileInput && uploadBtn) {
|
||||
uploadError.hidden = false;
|
||||
}
|
||||
} finally {
|
||||
uploadBtn.disabled = false; // never stale — success OR failure
|
||||
uploadBtn.textContent = "Upload & scan";
|
||||
// Never stale (PLAN §7.4) — but ONLY when no poll owns the
|
||||
// button: while startUploadPolling tracks the scan (202 / 409)
|
||||
// it stays disabled / "Processing…", so a finally restore here
|
||||
// would race the poll. No poll → the button is ours to restore.
|
||||
if (uploadPollTimer === null) restoreUploadButton();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -474,4 +704,8 @@ if (retryBtn) retryBtn.addEventListener("click", () => loadSources());
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
if (contentEl) contentEl.hidden = false;
|
||||
await loadSources();
|
||||
// Phase 64 (task 05): re-attach a running scan (a reload mid-scan
|
||||
// resumes the Processing state) or re-render a terminal run's
|
||||
// result line / error banner.
|
||||
await initUploadStatus();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user