Root cause (owner repro, verified in a real browser 2026-09-06): the five navbar views (Chat, RAG, Sources, Tuning, History) were separate HTML documents, so a navbar click was a REAL cross-document navigation — the chat page unloaded, the in-flight SSE fetch was aborted, and the phase-48 teardown (app/api/chat.py `finally`, "chat: turn cancelled") stopped the model. Observed: send question -> click RAG mid-stream -> click Chat -> the answer never finished: no `query_log` row, and on return a dangling question with no brain record (the pre-token pagehide partial persist skips because `acc` is empty). Phase-48 LOCKED-DECISION REFINEMENT (owner-confirmed 2026-09-06, flagged per AGENTS.md rule 3, not silently deviated): "real navigation cancels the fetch" now means LEAVING THE APP — tab close, external/other-document navigation, the Stop button. In-app navbar switches are client-side view switches and no longer cancel. Fix — Option A (SPA shell), chosen over B (Service Worker owns the stream) and C (server-side turn registry + resume): - frontend/index.html is the shell: ONE `<main id="main">` holds the five `<section class="view">` blocks; hidden views carry BOTH `hidden` and `inert` (WCAG — no focus/keyboard traversal). The shared header, the single `doc-modal-*` skeleton, and the `#app-version` footer each exist exactly once; the per-view copies from the four folded pages are dropped. - New frontend/assets/router.js (vanilla module — no framework, no bundler, No-CDN rule intact): lazy-imports a view module on FIRST show only (mount-once, hide-forever — the chat view's in-flight SSE reader persists across switches; that persistence IS the fix); intercepts same-shell navbar links with preventDefault + history.pushState (never a document load); handles popstate; single writer of `.nav-link` active state (is-active + aria-current), document.title, and the per-view meta description (values carried over from the old pages' heads, brand-resolved at write time). - Each folded page's JS becomes `export async function mount(root)` — root-scoped queries; `initSharedHeader()` dropped (the header boots once in the shell via the chat module; the admin flag comes from the same cached `fetchIsAdmin()` promise — zero extra requests). - app/main.py: a small list-driven route factory serves the shell for /tuning.html, /sources.html, /git-sources.html, /history.html — registered AFTER the API routers and BEFORE the static catch-all (routes-first). The phase-33 caching middleware applies no-cache + `?v=` rewriting unchanged; app/core/caching.py needed NO change (the view paths did not change — pinned by the integration tests). - The four old view .html files are DELETED (one source of truth); deep links to the old URLs keep working (the router picks the view from the pathname); `/?chat=<id>` is unaffected; the Containerfile bundles router.js (inlining the lazy view modules) and drops the folded page files. - app/schemas.py: HistoryTurn.text cap 4000 -> 32000 — the shell keeps long saved answers in the chat, and the old cap (stricter than the 24_000-char total history budget) 422-rejected any second turn in such a chat (found by the phase-42 E2E suite on the shell). Boundaries: login.html, shared.html, doc-edit.html, document.html REMAIN separate documents (flow pages, not navbar tabs); a mid-stream navigation to doc-edit/document.html still cancels per phase 48 (follow-up candidate, out of scope). The SSE API is unchanged. Real departures still cancel the turn — phase 48 intact (pinned by tests/e2e/test_stop_generation.py, unchanged, and by the new suite's real-departure control). Tests: - Phase-20 suite REWRITTEN to the new semantics (tests/e2e/test_sources_midstream_bug.py): a navbar switch no longer cancels — the stream survives the switch and the FULL answer settles; the pagehide partial persist REMAINS for real departures (the partial's exact shape — first streamed chunk prefix, no done metadata — is still pinned there). - NEW story suite tests/e2e/test_nav_switch_keeps_stream.py (mock LLM): the owner repro (send -> RAG mid-stream -> Chat: window sentinel survives = same document, FULL answer, exactly one brain turn in bor.chat.v1, exactly one settled query_log row, auto-saved row matches) + the same mid-stream switch against the other three views + the real-departure-still-cancels control + the no-switch baseline. - tests/unit/test_frontend_router.py: source-level pins of the router invariants (click interceptor targets ONLY same-shell view paths, pushState-only switches, mount-once guard, hidden+inert pair, single-writer active state/title); shell-route integration tests (each folded path serves the shell with no-cache + `?v=` body; a non-view path still 404s); the file-reading unit pins re-pointed at the shell (the four view files are gone — the shell is the source of truth). Verification (this commit): full suite green — 1565 unit+integration tests, app/ coverage 99% (>90% floor); ruff + pyright clean; the phase's E2E suites green in isolation (house protocol, AGENTS.md rule 9). Owner repro verified in a real browser against the real LLM (dev server :8010, headful Chromium): "tell me about everquest" -> RAG mid-stream -> Chat — the answer completed with one brain bubble and no error banner, `query_log` gained exactly one settled row (deflected=True: the dev KB holds no EverQuest docs — the settle, not the topic, is the proof), zero "chat: turn cancelled" lines for that turn; the control (real navigation to /shared.html mid-stream) still cancelled (no settled row, the cancel line logged, the partial persisted on return). Screenshots: .agents/screenshots/76_manual_*. Phase 76 (76_spa_nav_shell) complete — moved to .agents/phases/complete/.
917 lines
42 KiB
JavaScript
917 lines
42 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 scan 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 — 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 — 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 response counts render 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");
|
|
|
|
/* 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). */
|
|
|
|
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());
|
|
|
|
/* ---------- 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;
|
|
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();
|
|
}
|