Files
brain-of-reese/frontend/assets/git-sources.js
T

321 lines
13 KiB
JavaScript

/* Brain of Reese — Git sources admin page (phase 35, task 04).
*
* The page module for /git-sources.html: the admin-only manager for the
* stored git source list (git-sources table, phase 35 tasks 01/02).
* 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. URLs are ALWAYS rendered with
* textContent — never innerHTML (they 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}.
* §7.4 never-stale: the button disables + relabels "Adding…"
* while the request is out, re-enables ("Add source") 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 shape) shows the server detail inline under the
* form (role="alert", 422 shape-aware like the tuning forms) and
* keeps the input — the instruction survives.
* • remove — a row's Remove button asks window.confirm first
* (removal prunes the documents only on the NEXT sync — the
* confirm says so). Cancel → nothing; ok → the row button
* disables, DELETE /api/git-sources/{id}, loadSources(). A
* failure shows a per-row role="alert" error and re-enables the
* button. 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 or removing a repo
* does NOT clone, import, or prune — the header's Sync sources button
* (module-owned in assets/header.js) performs that; the page's hint
* box says so.
*
* 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");
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");
/* 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} git 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 URL in a mono <code> (textContent only — URLs may
contain credentials), 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). */
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 urlTd = document.createElement("td");
urlTd.className = "git-source-url-cell";
urlTd.title = s.url; // full URL on hover (long URLs scroll the wrapper)
const code = document.createElement("code");
code.textContent = s.url; // rendered as text, never as HTML
urlTd.appendChild(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 git source: ${s.url}`);
btn.innerHTML = REMOVE_ICON + "<span>Remove</span>";
const rowError = document.createElement("span");
rowError.className = "git-source-row-error";
rowError.setAttribute("role", "alert");
rowError.hidden = true;
btn.addEventListener("click", () => removeSource(s, btn, rowError));
actTd.append(btn, rowError);
} 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}) ----------
* Removal does not prune anything immediately — the next sync does
* (phase scope boundary), so the confirm says exactly that. Cancel →
* nothing; a failed delete → per-row role="alert" error + re-enabled
* button (never a stuck row); success → the list reloads. */
async function removeSource(s, btn, rowError) {
const ok = window.confirm(
"Remove this git source from the list? Its documents stay indexed until the next sync prunes them.",
);
if (!ok) return;
btn.disabled = true; // one delete per click
rowError.hidden = true;
try {
const r = await fetch(`/api/git-sources/${encodeURIComponent(s.id)}`, { method: "DELETE" });
if (!r.ok) {
rowError.textContent = await apiDetail(r, "Could not remove the git source — try again.");
rowError.hidden = false;
btn.disabled = false;
return;
}
announce("Git source removed.");
await loadSources(); // 204: the server confirmed — the list re-renders
} catch {
rowError.textContent = "Could not remove the git source — is the app reachable?";
rowError.hidden = false;
btn.disabled = false;
}
}
/* ---------- add (POST /api/git-sources) ---------- */
if (formEl && urlInput && addBtn) {
formEl.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 url = urlInput.value.trim();
if (!url) {
if (addError) {
addError.textContent = "Enter a git URL to add.";
addError.hidden = false;
}
return;
}
if (addError) addError.hidden = true; // a new attempt starts clean
addBtn.disabled = true; // §7.4: one POST per click
addBtn.textContent = "Adding…";
try {
const r = await fetch("/api/git-sources", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
if (r.ok) {
let createdId = null;
try {
createdId = (await r.json()).id ?? null;
} catch {
/* the 201 body is advisory — the reload is the truth */
}
urlInput.value = ""; // 201: the source is stored
announce("Git source added.");
await loadSources(); // the new row lands in the table
focusNewRow(createdId); // a11y: land the caret on the new row
return;
}
// 409 duplicate / 422 shape / anything else: the server detail
// inline (never echoing a URL the server wouldn't), form kept —
// the input survives so the fix is one edit, not a re-type.
if (addError) {
addError.textContent = await apiDetail(r, "Could not add the git source — try again.");
addError.hidden = false;
}
} catch {
if (addError) {
addError.textContent = "Could not add the git source — is the app reachable?";
addError.hidden = false;
}
} finally {
addBtn.disabled = false; // never stale — success OR failure
addBtn.textContent = "Add source";
}
});
}
/* 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();
})();