Files
brain-of-reese/frontend/assets/git-sources.js
T
ducoterra 3095c4c577 phase: 90_upload_no_scan
All verification is complete and green. Final report:

**Phase 90 (upload_no_scan) — final verification pass: all criteria verified, no fixes needed**

- Verified (not re-implemented): all 3 tasks already complete; working tree carries the phase's uncommitted changes
- Upload pipeline: `_run_upload` stops after row upsert; success = `{"message": "uploaded"}`, null/0/0 progress; `UploadOut` removed from `app/schemas.py`; gates/unpack/swap/failed states intact
- Copy: button "Upload", bare "Processing…", result line "Uploaded \<name\> — press Sync sources to import it."; hint + caption re-pointed at Sync; no "Upload &" remnants in `frontend/`
- Tests: `pytest tests/unit/test_git_sources.py tests/integration/test_git_sources_upload.py tests/unit/test_frontend_sync_upload.py` → 56 passed; E2E phase suite `tests/e2e/test_upload_no_scan.py` → 3 passed (zero docs after upload; ignore edit honored by Sync — 2 added, `notes/skipme.md` excluded; re-upload in-place, still zero docs); affected suites `test_archive_upload_sources.py` + `test_sync_upload_progress.py` + `test_source_removal_cleanup.py` → 15 passed
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **1808 passed, 99% coverage** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors

**Completion criteria:**
1. Upload settles "ready for sync", zero docs, row + ignore editor — ✅ (E2E test 1)
2. Button "Upload", no "Upload &" copy — ✅ (`rg` empty in frontend/, app/, tests/)
3. RAG-page "Sync sources" imports upload honoring edited ignores — ✅ (isolated E2E test 2)
4. Suite green, >90% coverage, lint clean — ✅ (99%, ruff+pyright clean)
5. Atomic commit — ✅ left to harness per executor rules (no git add/commit run; tree staged-ready)

**Deviations:** `regenerate_overview` still appears once in `git_sources.py` — inside `delete_git_source` (phase-69 locked total-removal behavior, not the upload leg; upload leg is scan-free). `.agents/PLAN.md` does not exist in this repo; locked decisions A1–A4 were enforced from the phase files.

**Next pending phase:** `91_admin_theme_tab` (in `todo/`).
2026-09-09 14:08:18 -04:00

1192 lines
56 KiB
JavaScript

/* Brain of Reese — Sources view (the git-sources manager; phase 35
* task 04; archive uploads, phase 49 task 03; phase 76 task 02: shell
* view module — formerly the standalone git-sources.html).
*
* The view 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 view's behaviour:
*
* Phase 76 (task 02) — shell view module (the "Sources" view of the
* ONE-document shell; /git-sources.html now serves the shell, and
* assets/router.js lazy-imports THIS module on first show):
*
* • the top-level boot is now `export async function mount(root)` —
* root is the view's <section id="view-git-sources">, and every
* DOM lookup scopes to root (the view ids stay unique across the
* shell — scoped lookups keep the module honest and testable).
* The router mounts a view ONCE (mount-once, hide-forever), so
* the bindings and the upload-progress state machine survive
* every switch: the upload poller is a self-chaining setTimeout
* started when an upload begins (never at boot), so progress
* continues while the user is on another view, and nothing
* refetches on re-show. The single toast node/timer stay module
* scope (one per document life — toasts never stack).
* • the initSharedHeader() call is DROPPED: in the shell the shared
* header boots exactly once, via the chat module (app.js) at shell
* boot — the view never re-boots it. The gate keeps fetchIsAdmin()
* — the SAME cached /api/whoami promise header.js exports (zero
* extra requests).
*
* • boot (the mount's tail) — fetchIsAdmin() (the cached whoami):
* 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, unpack-only in phase 90 —
* 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
* ("Upload") disables + relabels "Uploading…" while the request
* is out — but the transfer is 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
* background run — the processing state (bare "Processing…",
* disabled, title cleared) plus startUploadPolling(): a 2 s poll
* of GET /api/git-sources/upload/status that renders the bare
* "Processing…" label for the WHOLE run (phase 90, A2 — the run
* is unpack + register only: no file, no "(n/m)" counts, no
* title) and settles it: success → the ready-for-sync line
* ("Uploaded <name> — press Sync sources to import it.",
* fmtUploadResult off the status's {"message": "uploaded"}
* detail — the role=status result line) + the "Archive uploaded
* — press Sync sources to import it." 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 run re-enters the processing state + poll
* (a reload mid-run re-attaches — no second upload), a terminal
* run re-renders its result line / error banner (the safe name
* was page-local — lastUploadName is null after a reload — so the
* re-rendered line is the nameless "Uploaded — press Sync sources
* to import it.").
* • 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.
*
* Phase 77 (task 02) — the re-show refresh: the shell router
* dispatches `bor:view-refresh` on the view's section when the user
* RE-SHOWS an already-mounted view (a switch back onto it, a re-click
* of the Sources nav link, or back/forward) — the first show (mount)
* and boot never (the mount's own load is the first fetch). This
* module listens on root and re-runs `loadSources()`, whose re-call
* resets all three list states: the populated render (renderSources
* replaces the tbody + re-syncs the empty state) and the load error
* (hideLoadError() runs on the success path, so an error followed by
* a successful refresh clears it). The listener is armed only in the
* ADMIN branch, after the whoami gate passes: anonymous shows the
* gate and never fetches /api/git-sources (the Sources-page gate
* pattern).
*
* Phase 89 (task 05) — the per-source ignore-paths editor: every
* STORED row (s.id truthy — env-fallback rows carry NO button, A3:
* nothing is stored to edit) gets an "Ignore paths" button in the
* actions cell (left of Remove) that opens the page-local alertdialog
* (#ignore-editor-dialog — the EXACT #remove-confirm-dialog pattern,
* phase 69): #ignore-editor-source names the source (textContent ONLY
* — the same `value` expression makeRow uses — URLs may embed
* user:pass@ credentials, phase 32), the mono textarea prefills the
* row's stored list (one path per line — the phase-89 A1 prefix rule,
* stated in plain words in the helper copy), and focus lands on
* Cancel (the safe default); Escape / Cancel / the dim backdrop close
* as CANCEL (no request — focus returns to the row's trigger button).
* "Save" runs the §7.4 in-flight lifecycle: both buttons disable +
* the save relabels "Saving…" while the PATCH
* /api/git-sources/{id} is out (blank lines in the box are separators,
* not entries — split + trim + drop empty client-side; the server
* still rejects empty entries defensively, A4). 200 → close (focus
* return), loadSources (the row's "N ignored" count tag lands — the
* A5 replace semantics round-trip through GET), announce — the update
* confirmation is the LAST announcement (the reload's "N sources
* listed." must not overwrite it); non-2xx → the in-dialog
* role="alert" line (the server detail, apiDetail 422-shape-aware) +
* the buttons restored, the dialog STAYS open and the textarea
* content is KEPT (the instruction survives — tuning-form
* convention); network failure → the fixed reachable? line, same
* restore. Rows whose list is non-empty show the "N ignored" count
* tag next to the location <code> (text + a distinct background —
* never color alone, WCAG 1.4.1).
*
* 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 registers the source in place (the phase-64 background task —
* 202 + status endpoint) and STOPS THERE — no model check, no
* import, no overview refresh (phase 90, A1): the scan is the RAG
* page's "Sync sources" button's job (it imports the uploaded
* kind=local row with prune + the row's ignore list), and the result
* line points at that button.
*
* 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 — the shell
* loads the view module ONLY through the router's lazy import();
* esbuild inlines it into the router bundle in the image build).
*/
import { fetchIsAdmin } from "./header.js";
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)
export async function mount(root) {
/* ---------- page elements (git-sources.html, task 04) ---------- */
const gateEl = root.querySelector("#git-sources-gate");
const contentEl = root.querySelector("#git-sources-content");
const formEl = root.querySelector("#git-source-form");
const urlInput = root.querySelector("#git-source-url");
const addBtn = root.querySelector("#git-source-add");
const addError = root.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 no-count result line (phase 90) renders in the role=status
result line. */
const uploadFormEl = root.querySelector("#archive-upload-form");
const uploadFileInput = root.querySelector("#archive-upload-file");
const uploadBtn = root.querySelector("#archive-upload-btn");
const uploadError = root.querySelector("#archive-upload-error");
const uploadResult = root.querySelector("#archive-upload-result");
const loadErrorEl = root.querySelector("#git-sources-load-error");
const loadErrorText = root.querySelector("#git-sources-load-error-text");
const retryBtn = root.querySelector("#git-sources-retry");
const tableWrap = root.querySelector("#git-sources-table-wrap");
const tbody = root.querySelector("#git-sources-tbody");
const emptyEl = root.querySelector("#git-sources-empty");
const envNote = root.querySelector("#git-sources-env-note");
const announcer = root.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 = root.querySelector("#remove-confirm-dialog");
const removeBackdrop = root.querySelector(".remove-confirm-backdrop");
const removeSourceEl = root.querySelector("#remove-confirm-source");
const removeError = root.querySelector("#remove-confirm-error");
const removeCancelBtn = root.querySelector("#remove-confirm-cancel");
const removeRemoveBtn = root.querySelector("#remove-confirm-remove");
/* Phase 89: the per-source ignore-paths editor — the SAME page-local
alertdialog idiom as the remove-confirm modal (phase 69): static
markup in the shell's Sources view; this module owns the open /
cancel / save lifecycle. */
const ignoreDialog = root.querySelector("#ignore-editor-dialog");
const ignoreBackdrop = root.querySelector(".ignore-editor-backdrop");
const ignoreSourceEl = root.querySelector("#ignore-editor-source");
const ignoreTextarea = root.querySelector("#ignore-editor-textarea");
const ignoreErrorEl = root.querySelector("#ignore-editor-error");
const ignoreCancelBtn = root.querySelector("#ignore-editor-cancel");
const ignoreSaveBtn = root.querySelector("#ignore-editor-save");
/* 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);
/* Phase 89: the "N ignored" count tag — stored rows with a
non-empty list only (text + a distinct background, never color
alone — WCAG 1.4.1). The list round-trips: (s.ignore_paths ||
[]) is the same expression openIgnoreEditor prefills from. */
if (s.id && (s.ignore_paths || []).length > 0) {
const count = document.createElement("span");
count.className = "git-source-ignore-count";
count.textContent = `${s.ignore_paths.length} ignored`;
urlTd.append(count);
}
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) {
/* Phase 89: the per-row ignore-paths editor trigger (A3: stored
rows only — env-fallback rows fall through to the "from
.env" tag below). The aria-label is the ONLY place `value`
appears (setAttribute — never innerHTML); the text label is
static. Sits LEFT of Remove — the non-destructive action
first. */
const ignoreBtn = document.createElement("button");
ignoreBtn.type = "button";
ignoreBtn.className = "git-source-ignore";
ignoreBtn.setAttribute(
"aria-label",
`Edit ignored paths for ${kindLabel} source: ${value}`,
);
ignoreBtn.textContent = "Ignore paths";
ignoreBtn.addEventListener("click", () => openIgnoreEditor(s, ignoreBtn));
actTd.appendChild(ignoreBtn);
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);
/* ---------- ignore paths (PATCH /api/git-sources/{id}) — the per-row editor ----------
* Phase 89: a stored row's "Ignore paths" button (makeRow — left of
* Remove) opens the page-local alertdialog
* (#ignore-editor-dialog — the EXACT #remove-confirm-dialog idiom,
* phase 69) via openIgnoreEditor(s, triggerBtn): #ignore-editor-
* source names the source (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
* mono textarea PREFILLS the row's stored list (one path per line
* — the phase-89 A1 prefix rule, stated in plain words in the
* helper copy), the error line clears, and focus lands on Cancel
* (the safe default). Escape / Cancel / the dim backdrop close as
* CANCEL: no request, focus returns to the row's trigger button.
*
* "Save" (saveIgnorePaths) runs the §7.4 never-stale lifecycle:
* both buttons disable and the save relabels "Saving…" while the
* PATCH is out — a blank line in the box is a separator, not an
* entry (split + trim + drop empty lines client-side; the server
* still rejects empty entries defensively, A4). 200 → close
* (focus return) → loadSources (the "N ignored" count tag lands —
* the A5 replace round-trips through GET) → announce (the update
* confirmation is the LAST announcement — the reload's "N sources
* listed." must not overwrite it); non-2xx → the in-dialog
* role="alert" line (the server detail, apiDetail 422-shape-aware)
* + the buttons restored — the dialog STAYS open and the textarea
* content is KEPT (the instruction survives — the tuning-form
* convention); network failure → the fixed reachable? line, same
* restore. Env-fallback rows (id null) carry NO button (A3 —
* nothing is stored to edit). */
let ignoreTarget = null; // the row object of the open editor
let ignoreTriggerBtn = null; // the row's button — focus returns here on close
let ignoreInFlight = false; // §7.4: a PATCH is out (both buttons disabled)
function openIgnoreEditor(s, triggerBtn) {
if (!ignoreDialog || !ignoreTextarea) return; // defensive — the markup ships with the page
if (ignoreInFlight) return; // one editor at a time
const isLocal = s.kind === "local";
// The same `value` expression makeRow uses — textContent ONLY.
if (ignoreSourceEl) ignoreSourceEl.textContent = isLocal ? s.path ?? s.url : s.url;
ignoreTextarea.value = (s.ignore_paths || []).join("\n");
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "";
ignoreErrorEl.hidden = true; // a new attempt starts clean
}
ignoreInFlight = false;
ignoreTarget = s;
ignoreTriggerBtn = triggerBtn; // recorded for the focus return on close
ignoreDialog.hidden = false;
document.addEventListener("keydown", onIgnoreDialogKeydown);
// Cancel is the safe default — focus lands on it (the remove-
// dialog precedent; visibly: the global 3px :focus-visible
// outline).
if (ignoreCancelBtn) ignoreCancelBtn.focus();
}
/* Any close (cancel, success): hide the dialog, reset the textarea
+ error line, clear the target, detach the keydown handling, and
return focus to the row's "Ignore paths" button (WCAG 2.1). */
function closeIgnoreEditor() {
if (!ignoreDialog) return;
ignoreDialog.hidden = true;
ignoreInFlight = false;
if (ignoreTextarea) ignoreTextarea.value = "";
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "";
ignoreErrorEl.hidden = true;
}
if (ignoreCancelBtn) ignoreCancelBtn.disabled = false;
if (ignoreSaveBtn) {
ignoreSaveBtn.disabled = false;
ignoreSaveBtn.textContent = "Save";
}
document.removeEventListener("keydown", onIgnoreDialogKeydown);
const trigger = ignoreTriggerBtn;
ignoreTarget = null;
ignoreTriggerBtn = null;
if (trigger) trigger.focus(); // focus returns to the row's button
}
/* Escape / Cancel / backdrop all close as cancel — NO request. A
cancel is a no-op while a PATCH is in flight (no half-cancel of
an in-progress save; the buttons are disabled anyway, the
Escape/backdrop paths need this guard). */
function cancelIgnoreEditor() {
if (ignoreInFlight) return;
closeIgnoreEditor();
}
/* While open (attached in openIgnoreEditor, detached in
closeIgnoreEditor): Escape closes as cancel. */
function onIgnoreDialogKeydown(e) {
if (e.key === "Escape") {
e.preventDefault();
cancelIgnoreEditor();
}
}
async function saveIgnorePaths() {
if (!ignoreTarget || ignoreInFlight) return; // one request at a time
// The box's lines: one path per line — a blank line is a
// separator, not an entry (trim + drop empty; the server still
// rejects empties defensively, A4).
const lines = ignoreTextarea.value.split("\n").map((l) => l.trim()).filter(Boolean);
const t = ignoreTarget;
const value = t.kind === "local" ? (t.path ?? t.url) : t.url;
ignoreInFlight = true;
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "";
ignoreErrorEl.hidden = true;
}
if (ignoreCancelBtn) ignoreCancelBtn.disabled = true;
if (ignoreSaveBtn) {
ignoreSaveBtn.disabled = true;
ignoreSaveBtn.textContent = "Saving…"; // §7.4 in-flight label
}
try {
const r = await fetch(`/api/git-sources/${encodeURIComponent(t.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ignore_paths: lines }),
});
if (r.ok) {
// 200: the server replaced the row's list (A5).
closeIgnoreEditor(); // focus returns to the row's button
await loadSources(); // the "N ignored" count tag lands
// The update confirmation is the LAST announcement: the
// reload's "N sources listed." must not overwrite it (the
// same order as the remove flow).
announce(`Ignored paths updated for ${value}`);
return;
}
// non-2xx: the in-dialog role="alert" line (the server detail)
// — the dialog STAYS open and the textarea content is KEPT:
// the fix is one edit + retry, not a re-type.
if (ignoreErrorEl) {
ignoreErrorEl.textContent = await apiDetail(r, "Could not save the ignored paths — try again.");
ignoreErrorEl.hidden = false;
}
} catch {
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "Could not save the ignored paths — is the app reachable?";
ignoreErrorEl.hidden = false;
}
} finally {
// Never stale (PLAN §7.4): the failure paths re-enable BOTH
// buttons + relabel the save; the success path already closed
// the dialog (which resets them) — the restore is a no-op
// there.
ignoreInFlight = false;
if (ignoreCancelBtn) ignoreCancelBtn.disabled = false;
if (ignoreSaveBtn) {
ignoreSaveBtn.disabled = false;
ignoreSaveBtn.textContent = "Save";
}
}
}
/* The dialog's own buttons (static markup — wired once). */
if (ignoreCancelBtn) ignoreCancelBtn.addEventListener("click", cancelIgnoreEditor);
if (ignoreBackdrop) ignoreBackdrop.addEventListener("click", cancelIgnoreEditor);
if (ignoreSaveBtn) ignoreSaveBtn.addEventListener("click", saveIgnorePaths);
/* ---------- 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), unpack-only (phase 90) -------
* 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 background run (phase 90: UNPACK + REGISTER only — no scan):
* 202 → the page-local "Successfully uploaded — <file>" toast
* (showUploadToast — A2, safe to navigate away), the file input
* clears, and the processing state (bare "Processing…", disabled,
* title cleared) + startUploadPolling() own it — a 2 s poll of
* GET /api/git-sources/upload/status that renders the bare
* "Processing…" label for the WHOLE run (phase 90, A2 — the unpack
* has no file-level progress: no file, no "(n/m)" counts, no title)
* and settles it: success → the ready-for-sync line ("Uploaded
* <name> — press Sync sources to import it.", fmtUploadResult off
* the status's {"message": "uploaded"} detail) in the role=status
* result line + the "Archive uploaded — press Sync sources to
* import it." 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 run 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 phase-64 scan counts are superseded by phase 90.) */
/* The result line's text (phase 90, A2 — the no-count contract): the
status success detail is exactly {"message": "uploaded"} — the
sync-style counts the phase-64 line rendered are gone (the scan —
and its counts — belong to the Sync button, which renders them on
the RAG page). `name` is the accepted 202's safe source name
(lastUploadName) when the run started on this page; it is null
after a reload or on the 409 re-attach (the line still points at
the next step, only without the name). */
function fmtUploadResult(detail, name) {
if (detail && detail.message === "uploaded") {
return name
? `Uploaded ${name} — press Sync sources to import it.`
: "Uploaded — press Sync sources to import it.";
}
return "The upload finished.";
}
/* 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). */
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 background-run poll (phase 64 task 05, unpack-only in phase
* 90): 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 bare
* "Processing…" label for the whole run (phase 90, A2 — the unpack
* has no file-level progress: no file, no "(n/m)" counts, the title
* stays clear) + reschedule; success → stop + the ready-for-sync
* 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)
let lastUploadName = null; // phase 90: the accepted 202's safe source name — the result line's <name> (null after a reload / on the 409 re-attach)
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, bare "Processing…", title
* cleared (a live file never lands on it — phase 90: the run is
* unpack + register only, so the label stays bare). */
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";
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 bare label for the whole background run (phase
// 90, A2 — the unpack has no file-level progress, so no file,
// no counts, and the title stays clear).
if (status.state === "running") {
uploadBtn.textContent = "Processing…";
uploadBtn.title = "";
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
return;
}
stopUploadPolling();
if (status.state === "success") {
// The run finished (unpack + register only — phase 90): the
// ready-for-sync line (fmtUploadResult reads the no-count
// detail), 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, lastUploadName);
uploadResult.hidden = false;
}
announce("Archive uploaded — press Sync sources to import it.");
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 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 run re-enters the processing state
* + the poll (a reload mid-run 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;
* the name is unknown after a reload — lastUploadName is null — so
* the line is the nameless "Uploaded — press Sync sources to import
* it.", phase 90); 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).
// The safe name was page-local (lastUploadName is null after a
// reload) — the line still points at the next step (phase 90).
if (uploadResult) {
uploadResult.textContent = fmtUploadResult(status.detail, lastUploadName);
uploadResult.hidden = false;
}
return;
}
if (status.state === "failed") {
if (uploadError) {
uploadError.textContent = status.error || "The upload 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 (phase 64 A1) — the
// "successfully uploaded" moment: the toast fires NOW (A2),
// the file input clears, and the background run's poll takes
// over the button. The 202 body (UploadAccepted) carries the
// safe source name (the settled result line's <name>, phase
// 90 — recorded page-locally); 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 */
}
lastUploadName = name;
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 run (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());
/* ---------- view boot (phase 76 task 02) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* gate reads fetchIsAdmin() — the SAME cached whoami promise
* header.js exports (zero extra requests): anonymous visitors get
* the gate and NO /api/git-sources call (the Sources-page gate
* pattern); the admin gets the manager. */
const admin = await fetchIsAdmin();
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;
/* Phase 77 (task 02): a user-initiated re-show of this already-
mounted view makes the router dispatch bor:view-refresh on the
section — re-run loadSources then (its re-call resets the
populated / empty / load-error states). Armed ONLY here, after
the whoami gate passed: anonymous shows the gate and must never
fetch /api/git-sources (the Sources-page gate pattern). */
root.addEventListener("bor:view-refresh", () => loadSources());
await loadSources();
// Phase 64 (task 05): re-attach a running run (a reload mid-run
// resumes the bare Processing state) or re-render a terminal
// run's result line / error banner (phase 90: the unpack-only,
// ready-for-sync line).
await initUploadStatus();
}