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();
|
||||
})();
|
||||
|
||||
+159
-28
@@ -14,6 +14,13 @@
|
||||
* page "new chat" means going to the chat, fresh — the module clears
|
||||
* the phase-14 conversation key and navigates to "/").
|
||||
*
|
||||
* Phase 64 (task 04): the sync button is also the live progress face
|
||||
* of the BACKGROUND archive scan (task 03) — while an upload import
|
||||
* is in flight the button animates with the upload's current file
|
||||
* ("Importing <file>"), settles + refreshes the catalog when it
|
||||
* finishes, and re-attaches to it on page load. See the sync-button
|
||||
* block below for the full two-job contract.
|
||||
*
|
||||
* Phase 26: the table's path links open the document in the
|
||||
* almost-fullscreen modal overlay (assets/document-modal.js) on the
|
||||
* SAME page — no new tab, no navigation. The link keeps its
|
||||
@@ -36,6 +43,33 @@ import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
* opens an error modal (same as the former header.js module — recreated
|
||||
* here since the navbar button is gone).
|
||||
*
|
||||
* Phase 64 (task 04): the button reports the FILE being processed, not
|
||||
* just "Syncing…" — TWO jobs drive it. Each poll tick fetches BOTH
|
||||
* status endpoints — GET /api/sync/status +
|
||||
* GET /api/git-sources/upload/status — and applies this decision
|
||||
* tree, in order (startSyncPolling):
|
||||
* 1. sync running → "Syncing… <file> (n/m)" — bare "Syncing…" until
|
||||
* the import's first file (clone/pull, A4);
|
||||
* 2. upload running → "Importing <file> (n/m)" — the background
|
||||
* archive scan (the "clicked upload, then opened
|
||||
* sources" contract, A3);
|
||||
* 3. sync success → the phase-32 settle (counts + catalog refresh);
|
||||
* 4. sync failed → the phase-32 failure (banner + modal);
|
||||
* 5. upload success → settle "Sync sources" + catalog refresh
|
||||
* (loadDocs — the new documents must appear); the
|
||||
* upload's counts live on the Sources page, never
|
||||
* in #sync-result (A3);
|
||||
* 6. upload failed → settle "Sync sources" — the failure is the
|
||||
* Sources page's error banner, never this page's (A3);
|
||||
* 7. both idle → retry-ready idle.
|
||||
* The live label is the status endpoint's full source/relative/path
|
||||
* (A4): CSS ellipsizes #sync-label; the full untruncated path also
|
||||
* rides the button title (hover) and #sync-result (the aria-live
|
||||
* announcer — screen readers hear it). The load-time re-attach
|
||||
* (initSyncButton) re-enters a RUNNING upload the same way; a terminal
|
||||
* upload is a no-op there (the boot-time loadDocs() already shows the
|
||||
* current catalog).
|
||||
*
|
||||
* Elements: #sync-btn (the button), #sync-label (the text),
|
||||
* #sync-icon (the spinner icon), #sync-result (aria-live result
|
||||
* line), #sync-error-banner / #sync-error-text (error banner).
|
||||
@@ -70,6 +104,22 @@ function fmtSyncTime(iso) {
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/* Phase 64 (task 04): the live-file label. `kind` picks the prefix —
|
||||
* "sync" → "Syncing…", "upload" → "Importing" (the background scan's
|
||||
* word, A3). The current file — the status endpoint's full
|
||||
* source/relative/path (A4) — is appended while one is being processed;
|
||||
* the BARE prefix shows during the clone/pull (sync) or unpack (upload)
|
||||
* phase, before any file is indexed. The counts appear only once the
|
||||
* import has started (total > 0). CSS ellipsizes the button label; the
|
||||
* same untruncated text goes to the button title + #sync-result (the
|
||||
* aria-live announcer). */
|
||||
function fmtSyncLabel(kind, currentFile, done, total) {
|
||||
const prefix = kind === "upload" ? "Importing" : "Syncing…";
|
||||
let label = currentFile ? `${prefix} ${currentFile}` : prefix;
|
||||
if (total > 0) label += ` (${done}/${total})`;
|
||||
return label;
|
||||
}
|
||||
|
||||
function fmtSyncResult(detail) {
|
||||
const d = detail || {};
|
||||
const added = d.added || 0;
|
||||
@@ -88,15 +138,25 @@ function sanitizeSyncError(message) {
|
||||
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||||
}
|
||||
|
||||
function enterSyncRunningState() {
|
||||
/* The single running-state entry point (phase 64 task 04: the label
|
||||
* carries the live file — `kind` "sync" | "upload", the status
|
||||
* endpoint's current_file + done/total). Same mechanics as before
|
||||
* (disabled, aria-busy, spinning icon, no is-error) plus: the full
|
||||
* untruncated path on the button title (removed when null — no file
|
||||
* yet) and in #sync-result (the aria-live announcer reads the full
|
||||
* live path; CSS ellipsizes the button's label span only). */
|
||||
function enterSyncRunningState(kind, currentFile, done, total) {
|
||||
if (!syncBtn) return;
|
||||
syncBtn.disabled = true;
|
||||
syncBtn.setAttribute("aria-busy", "true");
|
||||
syncBtn.removeAttribute("title");
|
||||
if (currentFile) syncBtn.title = currentFile;
|
||||
else syncBtn.removeAttribute("title");
|
||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||
syncBtn.classList.remove("is-error");
|
||||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||||
if (syncLabel) syncLabel.textContent = "Syncing…";
|
||||
const label = fmtSyncLabel(kind, currentFile, done, total);
|
||||
if (syncLabel) syncLabel.textContent = label;
|
||||
if (syncResult) syncResult.textContent = label;
|
||||
}
|
||||
|
||||
function settleSyncButton(label) {
|
||||
@@ -193,15 +253,22 @@ function applySyncIdle(status) {
|
||||
emitSyncStatus(status || { state: "idle" });
|
||||
}
|
||||
|
||||
/* The 2 s poll (phase 64 task 04): each tick fetches BOTH jobs — the
|
||||
* sync AND the background upload scan — and applies the two-job
|
||||
* decision tree in order (see the section header). The 403 on the SYNC
|
||||
* fetch hides the button (the whoami backstop); a 403 on the UPLOAD
|
||||
* fetch is simply "no upload" (never a hide), and a network blip on
|
||||
* either fetch retries next tick. */
|
||||
function startSyncPolling() {
|
||||
if (syncPollTimer !== null) return;
|
||||
const tick = async () => {
|
||||
let status = null;
|
||||
let syncStatus = null;
|
||||
let uploadStatus = null;
|
||||
let notAdmin = false;
|
||||
try {
|
||||
const r = await fetch("/api/sync/status");
|
||||
if (r.status === 403) notAdmin = true;
|
||||
else if (r.ok) status = await r.json();
|
||||
else if (r.ok) syncStatus = await r.json();
|
||||
} catch { /* network blip — retry next tick */ }
|
||||
if (notAdmin) {
|
||||
stopSyncPolling();
|
||||
@@ -209,28 +276,66 @@ function startSyncPolling() {
|
||||
applySyncIdle();
|
||||
return;
|
||||
}
|
||||
if (!status) {
|
||||
// The SECOND job: the background upload scan (admin-only surface).
|
||||
try {
|
||||
const ur = await fetch("/api/git-sources/upload/status");
|
||||
if (ur.ok) uploadStatus = await ur.json();
|
||||
} catch { /* network blip — retry next tick */ }
|
||||
if (!syncStatus) {
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
return;
|
||||
}
|
||||
if (status.state === "success") {
|
||||
stopSyncPolling();
|
||||
applySyncSuccess(status);
|
||||
// 1. sync running: the live sync file (bare "Syncing…" until the
|
||||
// import's first file — A4).
|
||||
if (syncStatus.state === "running") {
|
||||
enterSyncRunningState(
|
||||
"sync", syncStatus.current_file, syncStatus.files_done, syncStatus.files_total
|
||||
);
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
return;
|
||||
}
|
||||
if (status.state === "failed") {
|
||||
stopSyncPolling();
|
||||
applySyncFailure(status);
|
||||
// 2. upload running: the same animation, the upload's file (A3).
|
||||
if (uploadStatus && uploadStatus.state === "running") {
|
||||
enterSyncRunningState(
|
||||
"upload", uploadStatus.current_file, uploadStatus.files_done, uploadStatus.files_total
|
||||
);
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
return;
|
||||
}
|
||||
if (status.state === "idle") {
|
||||
if (syncStatus.state === "success") {
|
||||
stopSyncPolling();
|
||||
applySyncIdle(status);
|
||||
applySyncSuccess(syncStatus);
|
||||
return;
|
||||
}
|
||||
// Still running: keep button state honest and re-schedule.
|
||||
enterSyncRunningState();
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
if (syncStatus.state === "failed") {
|
||||
stopSyncPolling();
|
||||
applySyncFailure(syncStatus);
|
||||
return;
|
||||
}
|
||||
// 5. upload success: settle + catalog refresh (A3 — the upload's
|
||||
// counts live on the Sources page; #sync-result stays empty).
|
||||
if (uploadStatus && uploadStatus.state === "success") {
|
||||
stopSyncPolling();
|
||||
settleSyncButton("Sync sources");
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
hideSyncError();
|
||||
emitSyncStatus({ state: "idle" });
|
||||
loadDocs();
|
||||
return;
|
||||
}
|
||||
// 6. upload failed: settle only — the failure is the Sources page's
|
||||
// error banner, never this page's (A3).
|
||||
if (uploadStatus && uploadStatus.state === "failed") {
|
||||
stopSyncPolling();
|
||||
settleSyncButton("Sync sources");
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
hideSyncError();
|
||||
emitSyncStatus({ state: "idle" });
|
||||
return;
|
||||
}
|
||||
// 7. both idle: settle retry-ready.
|
||||
stopSyncPolling();
|
||||
applySyncIdle(syncStatus);
|
||||
};
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
}
|
||||
@@ -253,8 +358,10 @@ async function startSync() {
|
||||
return;
|
||||
}
|
||||
if (r.status === 202 || r.status === 409) {
|
||||
enterSyncRunningState();
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
// Phase 64: the run is just starting (model check / clone-pull) —
|
||||
// bare "Syncing…" until the first polled file (A4); entering the
|
||||
// running state also clears #sync-result with the same label.
|
||||
enterSyncRunningState("sync", null, 0, 0);
|
||||
hideSyncError();
|
||||
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
|
||||
startSyncPolling();
|
||||
@@ -268,8 +375,12 @@ async function startSync() {
|
||||
});
|
||||
}
|
||||
|
||||
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running state,
|
||||
* a terminal run renders its last result. */
|
||||
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running
|
||||
* state, a terminal run renders its last result. Phase 64 (A3): with
|
||||
* the sync IDLE, an in-flight background upload scan adopts the button
|
||||
* the same way — the "user clicked upload, then opened sources" case;
|
||||
* a terminal upload is a no-op (the boot-time loadDocs() already shows
|
||||
* the current catalog). */
|
||||
async function initSyncButton() {
|
||||
if (!syncBtn) return;
|
||||
if (!(await fetchIsAdmin())) return;
|
||||
@@ -281,16 +392,36 @@ async function initSyncButton() {
|
||||
status = await r.json();
|
||||
} catch { return; }
|
||||
if (status.state === "running") {
|
||||
enterSyncRunningState();
|
||||
enterSyncRunningState(
|
||||
"sync", status.current_file, status.files_done, status.files_total
|
||||
);
|
||||
emitSyncStatus(status);
|
||||
startSyncPolling();
|
||||
} else if (status.state === "success") {
|
||||
applySyncSuccess(status);
|
||||
} else if (status.state === "failed") {
|
||||
applySyncFailure(status);
|
||||
} else {
|
||||
applySyncIdle(status);
|
||||
return;
|
||||
}
|
||||
if (status.state === "success") {
|
||||
applySyncSuccess(status);
|
||||
return;
|
||||
}
|
||||
if (status.state === "failed") {
|
||||
applySyncFailure(status);
|
||||
return;
|
||||
}
|
||||
// Sync idle: check the SECOND job — an in-flight upload scan re-attaches.
|
||||
let upload;
|
||||
try {
|
||||
const ur = await fetch("/api/git-sources/upload/status");
|
||||
if (ur.ok) upload = await ur.json();
|
||||
} catch { /* network blip — the idle settle below is still honest */ }
|
||||
if (upload && upload.state === "running") {
|
||||
enterSyncRunningState(
|
||||
"upload", upload.current_file, upload.files_done, upload.files_total
|
||||
);
|
||||
emitSyncStatus({ state: "running" });
|
||||
startSyncPolling();
|
||||
return;
|
||||
}
|
||||
applySyncIdle(status);
|
||||
}
|
||||
|
||||
if (syncBtn) {
|
||||
|
||||
@@ -1496,7 +1496,18 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sync-btn .sync-icon.is-spinning { animation: none; }
|
||||
}
|
||||
.sync-label { display: inline; }
|
||||
/* Phase 64: the live-file label ("Syncing… <file> (n/m)" / "Importing
|
||||
<file> (n/m)") — a long source/relative/path ellipsizes inside the
|
||||
pill; the full path lives in the button title + #sync-result (the
|
||||
aria-live announcer). */
|
||||
.sync-label {
|
||||
display: inline-block;
|
||||
max-width: min(16rem, 40vw);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
/* Last-result announcer — soft ink, small mono */
|
||||
.sync-result {
|
||||
display: block;
|
||||
|
||||
Reference in New Issue
Block a user