feat(sources): real-time file progress for sync and upload — background upload with success toast
This commit is contained in:
+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) {
|
||||
|
||||
Reference in New Issue
Block a user