Files
brain-of-reese/frontend/assets/git-sources.js
T
ducoterra 137d5fa1a5
Build and Push Containers / build-and-push-app (push) Successful in 1m29s
Build and Push Containers / build-and-push-db (push) Successful in 11s
feat(sources): removing a source deletes its files and index entries behind a confirmation modal
2026-09-02 15:55:33 -04:00

890 lines
40 KiB
JavaScript

/* Brain of Reese — Git sources admin page (phase 35, task 04;
* archive uploads, phase 49 task 03).
*
* The page module for /git-sources.html: the admin-only manager for the
* stored source list (git-sources table, phase 35 tasks 01/02) — git
* repo URLs (kind "git") and uploaded archives unpacked under
* BOR_UPLOAD_DIR (kind "local", phase 49; the phase-38
* local-directory form is gone — the kind=local API POST is
* unchanged, the page just no longer offers it).
* This module is the single owner of the page's behaviour:
*
* • boot — initSharedHeader() (one cached whoami, shared with the
* header toggling): anonymous → the sign-in gate shows and the
* manager stays hidden (the exact Sources page gate pattern, and
* NO /api/git-sources call is made); admin → gate hidden,
* #git-sources-content revealed, loadSources().
* • loadSources() — GET /api/git-sources → the table rows
* (#git-sources-tbody), the env-fallback note's visibility
* (from_env), and the empty state. Each row leads with its kind
* badge (Git/Local — text + color, never color alone) plus the
* location in a mono <code>: the git URL, or the full local path
* for kind "local" rows (phase 38). Values are ALWAYS rendered
* with textContent — never innerHTML (URLs may embed user:pass@
* credentials; phase 32's masking discipline). Non-2xx or a
* network failure renders the role="alert" load error with a
* retry button — never a stuck page.
* • add — #git-source-form submit → POST /api/git-sources {url}.
* The §7.4 never-stale lifecycle (wireAddForm): the button
* disables + relabels "Adding…" while the request is out,
* re-enables on success AND failure. 201 clears the input,
* reloads the list, and focuses the new row's Remove button
* (a11y); a failure (409 duplicate, 422 validation) shows the
* server detail inline under the form (role="alert", 422
* 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, 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
* §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 opens the page-local
* confirmation modal (#remove-confirm-dialog, a real
* role="alertdialog" — the native confirm() retired, phase 69):
* it
* names the source (#remove-confirm-source, textContent ONLY —
* URLs may embed user:pass@ credentials, phase 32) and states
* the full-removal policy — the row, the source's indexed
* documents (chunks + embeddings), and, for git clones and
* uploaded archives, the files on the server's disk, all removed
* immediately by the server-side DELETE. Cancel is the safe
* default: focus lands on Cancel at open; Escape, the Cancel
* button, and the dim backdrop all close as CANCEL (no request —
* focus returns to the row's Remove button). "Remove source" runs
* the §7.4 in-flight lifecycle IN the modal: both buttons
* disable + the confirm relabels "Removing…" while the DELETE is
* out — the in-flight window covers the whole server-side
* cleanup (DB prune → file removal → best-effort overview
* refresh; a slow LLM refresh is expected, not a stuck button —
* the same "wait for the terminal state" pattern as the
* Sync/Upload processing states). Navigating away mid-removal is
* not recommended: the row + index commit first, so the KB stays
* consistent; a rare interrupted file step leaves an inert orphan
* dir (no row → never imported again). 204 → close (focus
* return), loadSources(), then announce — the removal
* confirmation is the LAST announcement, so the reload's "N
* sources listed." cannot overwrite it (the announcer is the
* screen-reader confirmation for the destructive action);
* non-2xx → the in-modal role="alert" line (the server detail,
* apiDetail) + both
* buttons re-enabled + the confirm relabeled "Remove source" (the
* dialog stays open — the fix is one retry, not a re-search for
* the row); network failure → the fixed "is the app reachable?"
* line, same restore. Env-fallback rows (id null — the list
* comes from BOR_GIT_SOURCES, not the table) carry no Remove:
* nothing is stored to remove — they show a "from .env" tag
* instead.
* • announce(msg) — #git-sources-announcer (role=status,
* aria-live=polite): the screen-reader confirmation for loads,
* adds, and removals.
*
* Scope boundary (phase locked decisions): adding a git repo does
* NOT clone — the sync service (server-side) does that. Removing a
* source, however, performs the FULL cleanup server-side (phase 69):
* the row, the source's indexed documents (chunks + embeddings), and
* — for git clones and uploaded archives — the app-managed files on
* disk (foreign local directories are never touched), all in one
* action; the confirmation modal states exactly that, and the
* page's hint box matches. The Sync button still mirrors the
* remaining sources (upstream file churn is pruned on that run).
* The phase-49 upload is the other in-place exception: it unpacks
* and scans the 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
* runs (single-evaluation design: no direct <script> tag; esbuild
* inlines it into the page bundle in the image build).
*/
import { initSharedHeader } from "./header.js";
/* ---------- page elements (git-sources.html, task 04) ---------- */
const gateEl = document.querySelector("#git-sources-gate");
const contentEl = document.querySelector("#git-sources-content");
const formEl = document.querySelector("#git-source-form");
const urlInput = document.querySelector("#git-source-url");
const addBtn = document.querySelector("#git-source-add");
const addError = document.querySelector("#git-source-error");
/* Phase 49: the archive upload form (replaces the phase-38 local
directory form — same card, a file input instead of a path input).
The response counts render in the role=status result line. */
const uploadFormEl = document.querySelector("#archive-upload-form");
const uploadFileInput = document.querySelector("#archive-upload-file");
const uploadBtn = document.querySelector("#archive-upload-btn");
const uploadError = document.querySelector("#archive-upload-error");
const uploadResult = document.querySelector("#archive-upload-result");
const loadErrorEl = document.querySelector("#git-sources-load-error");
const loadErrorText = document.querySelector("#git-sources-load-error-text");
const retryBtn = document.querySelector("#git-sources-retry");
const tableWrap = document.querySelector("#git-sources-table-wrap");
const tbody = document.querySelector("#git-sources-tbody");
const emptyEl = document.querySelector("#git-sources-empty");
const envNote = document.querySelector("#git-sources-env-note");
const announcer = document.querySelector("#git-sources-announcer");
/* Phase 69: the remove confirmation modal (the native confirm()
retired) — static markup in git-sources.html; this module owns the
open / cancel / confirm lifecycle. */
const removeDialog = document.querySelector("#remove-confirm-dialog");
const removeBackdrop = document.querySelector(".remove-confirm-backdrop");
const removeSourceEl = document.querySelector("#remove-confirm-source");
const removeError = document.querySelector("#remove-confirm-error");
const removeCancelBtn = document.querySelector("#remove-confirm-cancel");
const removeRemoveBtn = document.querySelector("#remove-confirm-remove");
/* Polite live region: the screen-reader confirmation for loads, adds,
and removals (the phase-15 announcer pattern). */
function announce(message) {
if (announcer) announcer.textContent = message;
}
/* Added date — localized (toLocaleString); env-fallback rows carry
added_at null, and a corrupt timestamp must not blank the cell. */
function fmtDate(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString();
} catch {
return "—";
}
}
/* FastAPI error bodies: a string detail or the validation-error array
(the first entry's msg is the human line). Same extraction as
tuning.js — 422 shape-aware. */
async function apiDetail(r, fallback) {
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* ---------- load / render ---------- */
/* GET /api/git-sources → the table rows + env note + empty state.
The load error (role="alert" + retry) is the ONLY terminal state a
failed fetch may reach — never a stuck page. */
async function loadSources() {
let r;
try {
r = await fetch("/api/git-sources");
} catch {
showLoadError("Could not reach the server — is the app running?");
return;
}
if (!r.ok) {
showLoadError(await apiDetail(r, `The server could not list the git sources (${r.status}).`));
return;
}
let data;
try {
data = await r.json();
} catch {
showLoadError("The server sent an unreadable list — try again.");
return;
}
hideLoadError();
const sources = Array.isArray(data.sources) ? data.sources : [];
renderSources(sources, data.from_env === true);
announce(`${sources.length} source${sources.length === 1 ? "" : "s"} listed.`);
}
function showLoadError(message) {
if (loadErrorText) loadErrorText.textContent = message;
if (loadErrorEl) loadErrorEl.hidden = false;
// The list state is unknown — hide the table AND the empty state so
// the error is the only claim about the list's contents.
if (tableWrap) tableWrap.hidden = true;
if (emptyEl) emptyEl.hidden = true;
}
function hideLoadError() {
if (loadErrorEl) loadErrorEl.hidden = true;
if (loadErrorText) loadErrorText.textContent = "";
}
function renderSources(sources, fromEnv) {
if (envNote) envNote.hidden = !fromEnv;
if (tbody) {
tbody.replaceChildren();
for (const s of sources) tbody.appendChild(makeRow(s));
}
const hasRows = sources.length > 0;
if (tableWrap) tableWrap.hidden = !hasRows;
if (emptyEl) emptyEl.hidden = hasRows;
}
/* One row: the kind badge (Git/Local — phase 38) followed by the
location in a mono <code> (textContent only — git URLs may contain
credentials, local paths may contain anything), the localized added
date ("—" for env fallback rows), and the per-row Remove button —
or the "from .env" tag for env-fallback rows (id null: nothing is
stored to remove; the env note says where the active list comes
from). The value is the git URL for kind "git" rows and the full
local path for kind "local" rows (the API reports the path in both
`path` and `url`; `path` is the kind-typed field). */
const REMOVE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
function makeRow(s) {
const tr = document.createElement("tr");
if (s.id) tr.dataset.id = s.id;
const isLocal = s.kind === "local";
const value = isLocal ? (s.path ?? s.url) : s.url;
const kindLabel = isLocal ? "local" : "git";
const urlTd = document.createElement("td");
urlTd.className = "git-source-url-cell";
urlTd.title = value; // full URL/path on hover (long values scroll the wrapper)
const badge = document.createElement("span");
badge.className = `git-source-kind is-${isLocal ? "local" : "git"}`;
badge.textContent = isLocal ? "Local" : "Git"; // text + color, never color alone
const code = document.createElement("code");
code.textContent = value; // rendered as text, never as HTML
urlTd.append(badge, code);
tr.appendChild(urlTd);
const addedTd = document.createElement("td");
addedTd.textContent = fmtDate(s.added_at);
tr.appendChild(addedTd);
const actTd = document.createElement("td");
actTd.className = "git-source-actions-cell";
if (s.id) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "git-source-remove";
btn.setAttribute("aria-label", `Remove ${kindLabel} source: ${value}`);
btn.innerHTML = REMOVE_ICON + "<span>Remove</span>";
// Phase 69: opens the confirmation modal (the native confirm()
// retired) — the modal names the source and states the
// full-removal policy; the per-row error span is retired (the
// modal carries the in-flight error line).
btn.addEventListener("click", () => openRemoveConfirm(s, btn));
actTd.appendChild(btn);
} else {
const tag = document.createElement("span");
tag.className = "git-source-env-tag";
tag.textContent = "from .env";
actTd.appendChild(tag);
}
tr.appendChild(actTd);
return tr;
}
/* ---------- remove (DELETE /api/git-sources/{id}) — the confirmation modal ----------
* A row's Remove button opens the page-local alertdialog
* (#remove-confirm-dialog — the native confirm() retired, phase 69)
* via openRemoveConfirm(s, triggerBtn): #remove-confirm-source shows the
* row's value (textContent ONLY — the same `value` expression
* makeRow uses: s.path ?? s.url for local rows, s.url for git — URLs
* may embed user:pass@ credentials, phase 32), the error line clears,
* and focus lands on Cancel (the safe default for a destructive
* action). Escape / Cancel / the dim backdrop close as CANCEL: no
* request, focus returns to the row's Remove button.
*
* "Remove source" (confirmRemove) runs the §7.4 never-stale
* lifecycle IN the modal: both buttons disable and the confirm
* relabels "Removing…" while the DELETE is out — the in-flight
* window covers the whole server-side cleanup (DB prune → file
* removal → best-effort overview refresh), so a slow LLM refresh is
* expected, not a stuck button. Navigating away mid-removal is not
* recommended: the row + index commit first, so the KB stays
* consistent; a rare interrupted file step leaves an inert orphan
* dir (no row → never imported again). 204 → close (focus return),
* loadSources(), then announce — the removal confirmation is the
* LAST announcement (the reload's "N sources listed." must not
* overwrite it — the announcer is the screen-reader confirmation for
* the destructive action); non-2xx → the in-modal role="alert" line
* (the server detail, apiDetail 422-shape-aware) + both buttons
* re-enabled + the confirm relabeled "Remove source" (the dialog
* stays open — the fix is one retry, not a re-search for the row);
* network failure → the fixed reachable? line, same restore. */
let removeTriggerBtn = null; // the row's Remove button — focus returns here on close
let removeInFlight = false; // §7.4: a DELETE is out (both buttons disabled)
let removingId = null; // the row id of the open/in-flight removal
function openRemoveConfirm(s, triggerBtn) {
if (!removeDialog || !removeSourceEl) return; // defensive — the markup ships with the page
if (removeInFlight) return; // one removal at a time
const isLocal = s.kind === "local";
// The same `value` expression makeRow uses — textContent ONLY.
removeSourceEl.textContent = isLocal ? s.path ?? s.url : s.url;
if (removeError) {
removeError.textContent = "";
removeError.hidden = true; // a new attempt starts clean
}
removingId = s.id;
removeInFlight = false;
removeTriggerBtn = triggerBtn; // recorded for the focus return on close
removeDialog.hidden = false;
document.addEventListener("keydown", onRemoveDialogKeydown);
// Cancel is the safe default for a destructive action — focus
// lands on it (visibly: the global 3px :focus-visible outline).
if (removeCancelBtn) removeCancelBtn.focus();
}
/* Any close (cancel, success): hide the dialog, clear the error
line, reset the buttons, detach the keydown handling, and return
focus to the row's Remove button (WCAG 2.1). */
function closeRemoveConfirm() {
if (!removeDialog) return;
removeDialog.hidden = true;
removeInFlight = false;
removingId = null;
if (removeError) {
removeError.textContent = "";
removeError.hidden = true;
}
if (removeCancelBtn) removeCancelBtn.disabled = false;
if (removeRemoveBtn) {
removeRemoveBtn.disabled = false;
removeRemoveBtn.textContent = "Remove source";
}
document.removeEventListener("keydown", onRemoveDialogKeydown);
const trigger = removeTriggerBtn;
removeTriggerBtn = null;
if (trigger) trigger.focus(); // focus returns to the row's Remove button
}
/* Escape / Cancel / backdrop all close as cancel — NO request. A
cancel is a no-op while a DELETE is in flight (no half-cancel of
an in-progress server-side removal; the buttons are disabled
anyway, the Escape/backdrop paths need this guard). */
function cancelRemoveConfirm() {
if (removeInFlight) return;
closeRemoveConfirm();
}
/* While open (attached in openRemoveConfirm, detached in
closeRemoveConfirm): Escape cancels; Tab/Shift+Tab cycle between
the modal's two buttons (the only focusable elements — aria-modal
is honored for keyboard users, not just screen readers). */
function onRemoveDialogKeydown(e) {
if (e.key === "Escape") {
e.preventDefault();
cancelRemoveConfirm();
return;
}
if (e.key === "Tab" && removeCancelBtn && removeRemoveBtn) {
const leaving = e.shiftKey ? removeCancelBtn : removeRemoveBtn;
const wrapTo = e.shiftKey ? removeRemoveBtn : removeCancelBtn;
if (document.activeElement === leaving) {
e.preventDefault();
wrapTo.focus();
}
}
}
async function confirmRemove() {
if (!removingId || removeInFlight) return; // one request at a time
removeInFlight = true;
if (removeError) {
removeError.textContent = "";
removeError.hidden = true;
}
if (removeCancelBtn) removeCancelBtn.disabled = true;
if (removeRemoveBtn) {
removeRemoveBtn.disabled = true;
removeRemoveBtn.textContent = "Removing…"; // §7.4 in-flight label
}
try {
const r = await fetch(`/api/git-sources/${encodeURIComponent(removingId)}`, {
method: "DELETE",
});
if (r.ok) {
// 204: the server confirmed the total removal (row + index +
// app-managed files).
closeRemoveConfirm(); // focus returns to the row's Remove button
await loadSources(); // the row leaves the table
// The removal confirmation is the LAST announcement: the
// reload's "N sources listed." must not overwrite it (the
// announcer is the screen-reader confirmation for the
// destructive action — phase 69 task 03's E2E pins the success
// line on the announcer after a real removal).
announce("Source removed — its files and index entries were cleaned up.");
return;
}
// non-2xx: the in-modal role="alert" line (the server detail) —
// the dialog STAYS open: the fix is one retry, not a re-search
// for the row.
if (removeError) {
removeError.textContent = await apiDetail(r, "Could not remove the source — try again.");
removeError.hidden = false;
}
} catch {
if (removeError) {
removeError.textContent = "Could not remove the source — is the app reachable?";
removeError.hidden = false;
}
} finally {
// Never stale (PLAN §7.4): the failure paths re-enable BOTH
// buttons + relabel the confirm; the success path already closed
// the dialog (which resets them) — the restore is a no-op there.
removeInFlight = false;
if (removeCancelBtn) removeCancelBtn.disabled = false;
if (removeRemoveBtn) {
removeRemoveBtn.disabled = false;
removeRemoveBtn.textContent = "Remove source";
}
}
}
/* The modal's own buttons (static markup — wired once). */
if (removeCancelBtn) removeCancelBtn.addEventListener("click", cancelRemoveConfirm);
if (removeBackdrop) removeBackdrop.addEventListener("click", cancelRemoveConfirm);
if (removeRemoveBtn) removeRemoveBtn.addEventListener("click", confirmRemove);
/* ---------- add (POST /api/git-sources) — the git form ----------
* wireAddForm gives the form the §7.4 never-stale lifecycle: while
* the request is out the button disables + relabels "Adding…" and
* re-enables (idle label restored) on success AND failure. 201 clears
* the input, reloads the list, and focuses the new row's Remove button
* (a11y); a failure (409 duplicate, 422 validation) shows the server
* detail inline under the form (role="alert", 422 shape-aware via
* apiDetail) and keeps the input — the fix is one edit, not a re-type.
* 409/422 details are fixed generic strings (credential safety — the
* URL is never echoed). */
function wireAddForm(opts) {
const { form, input, btn, error } = opts;
if (!form || !input || !btn) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
// Client-side non-empty check (the input is `required` too — the
// browser's native prompt is the first line, this one the second).
const value = input.value.trim();
if (!value) {
if (error) {
error.textContent = opts.emptyMessage;
error.hidden = false;
}
return;
}
if (error) error.hidden = true; // a new attempt starts clean
btn.disabled = true; // §7.4: one POST per click
btn.textContent = "Adding…";
try {
const r = await fetch("/api/git-sources", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts.body(value)),
});
if (r.ok) {
let createdId = null;
try {
createdId = (await r.json()).id ?? null;
} catch {
/* the 201 body is advisory — the reload is the truth */
}
input.value = ""; // 201: the source is stored
announce(opts.addedMessage);
await loadSources(); // the new row lands in the table
focusNewRow(createdId); // a11y: land the caret on the new row
return;
}
// 409 duplicate / 422 validation / anything else: the server
// detail inline, form kept — the input survives so the fix is
// one edit, not a re-type.
if (error) {
error.textContent = await apiDetail(r, opts.failMessage);
error.hidden = false;
}
} catch {
if (error) {
error.textContent = opts.networkMessage;
error.hidden = false;
}
} finally {
btn.disabled = false; // never stale — success OR failure
btn.textContent = opts.idleLabel;
}
});
}
wireAddForm({
form: formEl,
input: urlInput,
btn: addBtn,
error: addError,
body: (url) => ({ url }),
emptyMessage: "Enter a git URL to add.",
failMessage: "Could not add the git source — try again.",
networkMessage: "Could not add the git source — is the app reachable?",
addedMessage: "Git source added.",
idleLabel: "Add source",
});
/* ---------- 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). 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;
const updated = d.updated || 0;
const parts = [`${added} added`];
if (updated > 0) parts.push(`${updated} updated`);
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
parts.push(`${d.unchanged || 0} unchanged`);
}
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
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();
// Client-side no-file check (the input is `required` too — the
// browser's native prompt is the first line, this one the second).
const file = uploadFileInput.files && uploadFileInput.files[0];
if (!file) {
if (uploadError) {
uploadError.textContent = "Choose an archive file to upload.";
uploadError.hidden = false;
}
return;
}
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…"; // 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
// Content-Type header.
const r = await fetch("/api/git-sources/upload", {
method: "POST",
body: new FormData(uploadFormEl),
});
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 {
const data = await r.json();
if (data && typeof data.name === "string" && data.name) name = data.name;
} catch {
/* body parse failure — the picked file's name degrades fine */
}
showUploadToast(`Successfully uploaded — ${name}`);
uploadFileInput.value = ""; // 202: the archive is on the server
enterUploadProcessingState();
startUploadPolling();
return;
}
// 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) {
uploadError.textContent = await apiDetail(r, "Could not upload the archive — try again.");
uploadError.hidden = false;
}
} catch {
if (uploadError) {
uploadError.textContent = "Could not upload the archive — is the app reachable?";
uploadError.hidden = false;
}
} finally {
// 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();
}
});
}
/* After a successful add, focus the new row's Remove button so the
keyboard/screen-reader user lands where the new data is. The 201
body carries the row id (tr[data-id]); without one, the first row
is the fallback (the list is small and ordered). */
function focusNewRow(createdId) {
if (!tbody) return;
const row = createdId
? tbody.querySelector(`tr[data-id="${CSS.escape(createdId)}"]`)
: null;
const target = (row || tbody.querySelector("tr"))?.querySelector(".git-source-remove");
if (target) target.focus();
}
/* ---------- retry + boot ---------- */
if (retryBtn) retryBtn.addEventListener("click", () => loadSources());
(async () => {
// The shared header FIRST (Sign in/out + the admin-only nav links +
// the Sync button — one cached whoami), then the gate: anonymous
// visitors get the gate and NO /api/git-sources call (the Sources
// page gate pattern); the admin gets the manager.
const admin = await initSharedHeader();
if (!admin) {
if (gateEl) gateEl.hidden = false;
if (contentEl) contentEl.hidden = true; // ships hidden — stays hidden
return;
}
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();
})();