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/`).
615 lines
25 KiB
JavaScript
615 lines
25 KiB
JavaScript
/* Brain of Reese — RAG view (the knowledge base catalog; phase 76
|
||
* task 02: shell view module — formerly the standalone sources.html).
|
||
*
|
||
* Wires the real `GET /api/docs` endpoint (import phase): stat cards +
|
||
* full-width document table, or the designed empty state when nothing
|
||
* is indexed yet. Cells are built with DOM APIs (textContent) — never
|
||
* innerHTML with document-derived data (XSS-safe by construction).
|
||
*
|
||
* Phase 76 (task 02) — shell view module (the "RAG" view of the
|
||
* ONE-document shell; /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-rag">, 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,
|
||
* the sync state machine, and the in-flight poll survive every
|
||
* switch.
|
||
* • 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 admin gate (sync button
|
||
* reveal + the anonymous catalog gate) keeps fetchIsAdmin() — the
|
||
* SAME cached /api/whoami promise header.js exports (zero extra
|
||
* requests).
|
||
* • the document modal needs NO wiring change: the shell keeps
|
||
* EXACTLY ONE #doc-modal-* skeleton (the chat's, body level), and
|
||
* both app.js (chat chips) and this module (RAG row links) open
|
||
* documents through the shared openDocumentModal(...) against that
|
||
* single instance (assets/document-modal.js, resolved by
|
||
* document-level querySelector at import).
|
||
*
|
||
* The sync button (phase 32) + the live two-job progress contract
|
||
* (phase 64 task 04) and the table rows (phase 26: same-page modal
|
||
* links) are unchanged in content — only the boot shape moved.
|
||
*
|
||
* 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 RAG 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 `loadDocs()`, which is now re-entrant:
|
||
* a re-load drops the tbody's rows BEFORE the fetch, so a refresh
|
||
* from a populated list into an empty result replaces the list
|
||
* (no ghost rows) — the History pattern (task 01). #sources-empty
|
||
* lives OUTSIDE the tbody (a .empty-state div), so the top clear is
|
||
* a bare replaceChildren(). The listener is armed only in the ADMIN
|
||
* branch, after the whoami gate passes: anonymous shows the gate and
|
||
* never fetches /api/docs (the phase-16 soft rule).
|
||
*/
|
||
|
||
import { fetchIsAdmin } from "./header.js";
|
||
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
||
|
||
/* Viewer link (phase 10) — same encoded URL the chat chips use; both
|
||
* query values are percent-encoded (paths contain slashes, sometimes
|
||
* spaces). Phase 26: this is the href the .doc-link CARRIES (no-JS /
|
||
* context-menu escape hatch) — the left-click opens the same-page modal
|
||
* instead. */
|
||
export function documentUrl(source, path) {
|
||
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||
}
|
||
|
||
export async function mount(root) {
|
||
/* ---------- Sync sources button (Sources page only) ----------
|
||
*
|
||
* The §7.4 never-stale lifecycle: idle → click → POST /api/sync
|
||
* → running (2 s poll of GET /api/sync/status) → success | failed.
|
||
* Admin-only: the button SHIPS hidden and the view boot below reveals
|
||
* it for the admin on the SAME cached whoami fetchIsAdmin() reads
|
||
* (no extra fetch); the 403 branches stay as defense in depth. A failed run
|
||
* opens an error modal (same as the former header.js module — recreated
|
||
* here since the navbar button is gone).
|
||
*
|
||
* Phase 64 (task 04): the button reports the FILE being processed, not
|
||
* just "Syncing…" — TWO jobs drive it. Each poll tick fetches BOTH
|
||
* status endpoints — GET /api/sync/status +
|
||
* GET /api/git-sources/upload/status — and applies this decision
|
||
* tree, in order (startSyncPolling):
|
||
* 1. sync running → "Syncing… <file> (n/m)" — bare "Syncing…" until
|
||
* the import's first file (clone/pull, A4);
|
||
* 2. upload running → BARE "Importing…" — the background upload
|
||
* RUN (phase 90: unpack + register only, no
|
||
* scan — its status never carries a file or
|
||
* counts; the "clicked upload, then opened
|
||
* sources" contract, A3);
|
||
* 3. sync success → the phase-32 settle (counts + catalog refresh);
|
||
* 4. sync failed → the phase-32 failure (banner + modal);
|
||
* 5. upload success → settle "Sync sources" + catalog refresh
|
||
* (loadDocs — phase 90: an upload no longer
|
||
* changes the KB, the re-read is a no-op safety
|
||
* net); the upload's result line lives on the
|
||
* Sources page, never in #sync-result (A3);
|
||
* 6. upload failed → settle "Sync sources" — the failure is the
|
||
* Sources page's error banner, never this page's (A3);
|
||
* 7. both idle → retry-ready idle.
|
||
* The live label is the status endpoint's full source/relative/path
|
||
* (A4): CSS ellipsizes #sync-label; the full untruncated path also
|
||
* rides the button title (hover) and #sync-result (the aria-live
|
||
* announcer — screen readers hear it). The load-time re-attach
|
||
* (initSyncButton) re-enters a RUNNING upload the same way; a terminal
|
||
* upload is a no-op there (the boot-time loadDocs() already shows the
|
||
* current catalog).
|
||
*
|
||
* Elements: #sync-btn (the button), #sync-label (the text),
|
||
* #sync-icon (the spinner icon), #sync-result (aria-live result
|
||
* line), #sync-error-banner / #sync-error-text (error banner).
|
||
*/
|
||
const syncBtn = root.querySelector("#sync-btn");
|
||
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
||
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||
const syncResult = root.querySelector("#sync-result");
|
||
const syncErrorBanner = root.querySelector("#sync-error-banner");
|
||
const syncErrorText = root.querySelector("#sync-error-text");
|
||
|
||
const SYNC_POLL_MS = 2000;
|
||
let syncPollTimer = null;
|
||
let lastSyncState = null;
|
||
|
||
function emitSyncStatus(status) {
|
||
lastSyncState = status.state;
|
||
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
|
||
}
|
||
|
||
function stopSyncPolling() {
|
||
if (syncPollTimer !== null) {
|
||
clearTimeout(syncPollTimer);
|
||
syncPollTimer = null;
|
||
}
|
||
}
|
||
|
||
function fmtSyncTime(iso) {
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
const pad = (n) => String(n).padStart(2, "0");
|
||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
/* Phase 64 (task 04): the live-file label. `kind` picks the prefix —
|
||
* "sync" → "Syncing…", "upload" → "Importing" (the background run's
|
||
* word, A3). The current file — the status endpoint's full
|
||
* source/relative/path (A4) — is appended while one is being processed;
|
||
* the BARE prefix shows during the clone/pull (sync), before any file
|
||
* is indexed. Phase 90: the upload run is unpack + register only (no
|
||
* scan), so its status never carries a file or counts — the
|
||
* "Importing" label is always the bare one. The counts appear only
|
||
* once the import has started (total > 0). CSS ellipsizes the button
|
||
* label; the same untruncated text goes to the button title +
|
||
* #sync-result (the aria-live announcer). */
|
||
function fmtSyncLabel(kind, currentFile, done, total) {
|
||
const prefix = kind === "upload" ? "Importing" : "Syncing…";
|
||
let label = currentFile ? `${prefix} ${currentFile}` : prefix;
|
||
if (total > 0) label += ` (${done}/${total})`;
|
||
return label;
|
||
}
|
||
|
||
function fmtSyncResult(detail) {
|
||
const d = detail || {};
|
||
const added = d.added || 0;
|
||
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(" · ");
|
||
}
|
||
|
||
function sanitizeSyncError(message) {
|
||
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
|
||
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||
}
|
||
|
||
/* The single running-state entry point (phase 64 task 04: the label
|
||
* carries the live file — `kind` "sync" | "upload", the status
|
||
* endpoint's current_file + done/total). Same mechanics as before
|
||
* (disabled, aria-busy, spinning icon, no is-error) plus: the full
|
||
* untruncated path on the button title (removed when null — no file
|
||
* yet) and in #sync-result (the aria-live announcer reads the full
|
||
* live path; CSS ellipsizes the button's label span only). */
|
||
function enterSyncRunningState(kind, currentFile, done, total) {
|
||
if (!syncBtn) return;
|
||
syncBtn.disabled = true;
|
||
syncBtn.setAttribute("aria-busy", "true");
|
||
if (currentFile) syncBtn.title = currentFile;
|
||
else syncBtn.removeAttribute("title");
|
||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||
syncBtn.classList.remove("is-error");
|
||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||
const label = fmtSyncLabel(kind, currentFile, done, total);
|
||
if (syncLabel) syncLabel.textContent = label;
|
||
if (syncResult) syncResult.textContent = label;
|
||
}
|
||
|
||
function settleSyncButton(label) {
|
||
if (!syncBtn) return;
|
||
syncBtn.disabled = false;
|
||
syncBtn.removeAttribute("aria-busy");
|
||
syncBtn.removeAttribute("title");
|
||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||
syncBtn.classList.remove("is-error");
|
||
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
||
if (syncLabel) syncLabel.textContent = label;
|
||
}
|
||
|
||
function showSyncError(detail) {
|
||
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
|
||
if (syncErrorBanner) syncErrorBanner.hidden = false;
|
||
}
|
||
|
||
function hideSyncError() {
|
||
if (syncErrorText) syncErrorText.textContent = "";
|
||
if (syncErrorBanner) syncErrorBanner.hidden = true;
|
||
}
|
||
|
||
/* ---------- sync failure modal (recreated here since the navbar button is gone) ---------- */
|
||
let syncModal = null;
|
||
let syncModalReturnFocus = null;
|
||
|
||
function createSyncModal() {
|
||
const backdrop = document.createElement("div");
|
||
backdrop.className = "sync-modal-backdrop";
|
||
backdrop.innerHTML =
|
||
'<div class="sync-modal" role="alertdialog" aria-modal="true" ' +
|
||
'aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">' +
|
||
'<h2 id="sync-modal-title">Sync failed</h2>' +
|
||
'<p id="sync-modal-error"></p>' +
|
||
'<button type="button" class="sync-modal-close" aria-label="Close error dialog">\u00d7</button>' +
|
||
"</div>";
|
||
document.body.appendChild(backdrop);
|
||
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
|
||
backdrop.addEventListener("click", (e) => {
|
||
if (e.target === backdrop) closeSyncModal();
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
|
||
});
|
||
return backdrop;
|
||
}
|
||
|
||
function showSyncModal(error) {
|
||
if (!syncBtn || !document.body) return;
|
||
if (!syncModal) syncModal = createSyncModal();
|
||
syncModal.querySelector("#sync-modal-error").textContent = error;
|
||
if (syncModal.classList.contains("is-open")) return;
|
||
const active = document.activeElement;
|
||
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
|
||
syncModal.classList.add("is-open");
|
||
syncModal.querySelector(".sync-modal-close").focus();
|
||
}
|
||
|
||
function closeSyncModal() {
|
||
if (!syncModal || !syncModal.classList.contains("is-open")) return;
|
||
syncModal.classList.remove("is-open");
|
||
const target = syncModalReturnFocus;
|
||
syncModalReturnFocus = null;
|
||
if (target && document.contains(target)) target.focus();
|
||
}
|
||
|
||
function applySyncSuccess(status) {
|
||
const time = fmtSyncTime(status.finished_at);
|
||
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
||
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
|
||
hideSyncError();
|
||
emitSyncStatus(status);
|
||
// Refresh the catalog live — the KB just changed.
|
||
loadDocs();
|
||
}
|
||
|
||
function applySyncFailure(status) {
|
||
const error = sanitizeSyncError(status.error);
|
||
settleSyncButton("Sync sources");
|
||
if (syncBtn) {
|
||
syncBtn.title = error;
|
||
syncBtn.setAttribute("aria-label", error);
|
||
syncBtn.classList.add("is-error");
|
||
}
|
||
if (syncResult) syncResult.textContent = "";
|
||
showSyncError(error);
|
||
emitSyncStatus(status);
|
||
showSyncModal(error);
|
||
}
|
||
|
||
function applySyncIdle(status) {
|
||
settleSyncButton("Sync sources");
|
||
emitSyncStatus(status || { state: "idle" });
|
||
}
|
||
|
||
/* The 2 s poll (phase 64 task 04): each tick fetches BOTH jobs — the
|
||
* sync AND the background upload run (phase 90: unpack + register,
|
||
* no scan) — and applies the two-job decision tree in order (see the
|
||
* section header). The 403 on the SYNC fetch hides the button (the
|
||
* whoami backstop); a 403 on the UPLOAD fetch is simply "no upload"
|
||
* (never a hide), and a network blip on either fetch retries next
|
||
* tick. */
|
||
function startSyncPolling() {
|
||
if (syncPollTimer !== null) return;
|
||
const tick = async () => {
|
||
let syncStatus = null;
|
||
let uploadStatus = null;
|
||
let notAdmin = false;
|
||
try {
|
||
const r = await fetch("/api/sync/status");
|
||
if (r.status === 403) notAdmin = true;
|
||
else if (r.ok) syncStatus = await r.json();
|
||
} catch { /* network blip — retry next tick */ }
|
||
if (notAdmin) {
|
||
stopSyncPolling();
|
||
if (syncBtn) syncBtn.hidden = true;
|
||
applySyncIdle();
|
||
return;
|
||
}
|
||
// The SECOND job: the background upload run (phase 90: unpack +
|
||
// register only — no scan; admin-only surface).
|
||
try {
|
||
const ur = await fetch("/api/git-sources/upload/status");
|
||
if (ur.ok) uploadStatus = await ur.json();
|
||
} catch { /* network blip — retry next tick */ }
|
||
if (!syncStatus) {
|
||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||
return;
|
||
}
|
||
// 1. sync running: the live sync file (bare "Syncing…" until the
|
||
// import's first file — A4).
|
||
if (syncStatus.state === "running") {
|
||
enterSyncRunningState(
|
||
"sync", syncStatus.current_file, syncStatus.files_done, syncStatus.files_total
|
||
);
|
||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||
return;
|
||
}
|
||
// 2. upload running: the same animation, the upload's file (A3).
|
||
if (uploadStatus && uploadStatus.state === "running") {
|
||
enterSyncRunningState(
|
||
"upload", uploadStatus.current_file, uploadStatus.files_done, uploadStatus.files_total
|
||
);
|
||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||
return;
|
||
}
|
||
if (syncStatus.state === "success") {
|
||
stopSyncPolling();
|
||
applySyncSuccess(syncStatus);
|
||
return;
|
||
}
|
||
if (syncStatus.state === "failed") {
|
||
stopSyncPolling();
|
||
applySyncFailure(syncStatus);
|
||
return;
|
||
}
|
||
// 5. upload success: settle + catalog refresh (A3 — the upload's
|
||
// counts live on the Sources page; #sync-result stays empty).
|
||
if (uploadStatus && uploadStatus.state === "success") {
|
||
stopSyncPolling();
|
||
settleSyncButton("Sync sources");
|
||
if (syncResult) syncResult.textContent = "";
|
||
hideSyncError();
|
||
emitSyncStatus({ state: "idle" });
|
||
loadDocs();
|
||
return;
|
||
}
|
||
// 6. upload failed: settle only — the failure is the Sources page's
|
||
// error banner, never this page's (A3).
|
||
if (uploadStatus && uploadStatus.state === "failed") {
|
||
stopSyncPolling();
|
||
settleSyncButton("Sync sources");
|
||
if (syncResult) syncResult.textContent = "";
|
||
hideSyncError();
|
||
emitSyncStatus({ state: "idle" });
|
||
return;
|
||
}
|
||
// 7. both idle: settle retry-ready.
|
||
stopSyncPolling();
|
||
applySyncIdle(syncStatus);
|
||
};
|
||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||
}
|
||
|
||
async function startSync() {
|
||
let r;
|
||
try {
|
||
r = await fetch("/api/sync", { method: "POST" });
|
||
} catch {
|
||
applySyncFailure({
|
||
state: "failed",
|
||
error: "Could not reach the server to start the sync — try again.",
|
||
});
|
||
return;
|
||
}
|
||
if (r.status === 403) {
|
||
stopSyncPolling();
|
||
if (syncBtn) syncBtn.hidden = true;
|
||
applySyncIdle();
|
||
return;
|
||
}
|
||
if (r.status === 202 || r.status === 409) {
|
||
// Phase 64: the run is just starting (model check / clone-pull) —
|
||
// bare "Syncing…" until the first polled file (A4); entering the
|
||
// running state also clears #sync-result with the same label.
|
||
enterSyncRunningState("sync", null, 0, 0);
|
||
hideSyncError();
|
||
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
|
||
startSyncPolling();
|
||
return;
|
||
}
|
||
let detail = "";
|
||
try { detail = (await r.json()).detail || ""; } catch { /* non-JSON */ }
|
||
applySyncFailure({
|
||
state: "failed",
|
||
error: detail || `The server refused to start the sync (${r.status}).`,
|
||
});
|
||
}
|
||
|
||
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running
|
||
* state, a terminal run renders its last result. Phase 64 (A3): with
|
||
* the sync IDLE, an in-flight background upload RUN (phase 90: unpack
|
||
* + register — the bare "Importing…" label) adopts the button the
|
||
* same way — the "user clicked upload, then opened sources" case; a
|
||
* terminal upload is a no-op (the boot-time loadDocs() already shows
|
||
* the current catalog). */
|
||
async function initSyncButton() {
|
||
if (!syncBtn) return;
|
||
if (!(await fetchIsAdmin())) return;
|
||
let status;
|
||
try {
|
||
const r = await fetch("/api/sync/status");
|
||
if (r.status === 403) { syncBtn.hidden = true; return; }
|
||
if (!r.ok) return;
|
||
status = await r.json();
|
||
} catch { return; }
|
||
if (status.state === "running") {
|
||
enterSyncRunningState(
|
||
"sync", status.current_file, status.files_done, status.files_total
|
||
);
|
||
emitSyncStatus(status);
|
||
startSyncPolling();
|
||
return;
|
||
}
|
||
if (status.state === "success") {
|
||
applySyncSuccess(status);
|
||
return;
|
||
}
|
||
if (status.state === "failed") {
|
||
applySyncFailure(status);
|
||
return;
|
||
}
|
||
// Sync idle: check the SECOND job — an in-flight upload run
|
||
// re-attaches (the bare "Importing…" label — phase 90: unpack +
|
||
// register only).
|
||
let upload;
|
||
try {
|
||
const ur = await fetch("/api/git-sources/upload/status");
|
||
if (ur.ok) upload = await ur.json();
|
||
} catch { /* network blip — the idle settle below is still honest */ }
|
||
if (upload && upload.state === "running") {
|
||
enterSyncRunningState(
|
||
"upload", upload.current_file, upload.files_done, upload.files_total
|
||
);
|
||
emitSyncStatus({ state: "running" });
|
||
startSyncPolling();
|
||
return;
|
||
}
|
||
applySyncIdle(status);
|
||
}
|
||
|
||
if (syncBtn) {
|
||
syncBtn.addEventListener("click", startSync);
|
||
initSyncButton();
|
||
}
|
||
|
||
const tbody = root.querySelector("#docs-tbody");
|
||
const emptyEl = root.querySelector("#sources-empty");
|
||
const tableWrap = root.querySelector(".table-wrap");
|
||
const statCards = root.querySelector("#stat-cards");
|
||
const gateEl = root.querySelector("#sources-gate");
|
||
const statDocs = root.querySelector("#stat-docs");
|
||
const statChunks = root.querySelector("#stat-chunks");
|
||
const statLast = root.querySelector("#stat-last");
|
||
|
||
/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
|
||
* sign-in gate (stat cards + table hidden) and NO /api/docs call — the
|
||
* catalog is admin-only. The document viewer itself stays public (the
|
||
* soft rule), so the gate copy points at what keeps working.
|
||
* Phase 76 (task 02): the whoami request is the shared header module's
|
||
* cached promise — fetchIsAdmin(), the same single request the shell's
|
||
* header boots (zero extra requests). */
|
||
function fmtDate(iso) {
|
||
try {
|
||
return new Date(iso).toLocaleString();
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
|
||
/* Phase 77 (task 02): re-entrant — the top clear (the History
|
||
pattern from task 01) drops the tbody's rows BEFORE the fetch,
|
||
so a re-show refresh from a populated list into an empty result
|
||
replaces the list instead of leaving ghost rows. #sources-empty
|
||
lives OUTSIDE the tbody (a .empty-state div, not a row), so the
|
||
clear is a bare replaceChildren(). Phase 79 (task 04): the clear
|
||
alone is NOT enough when two loads interleave — the boot re-attach
|
||
(applySyncSuccess → loadDocs) and the boot-time loadDocs both clear
|
||
first, then the SLOWER fetch appends after the newer load's clear,
|
||
duplicating every row (2×). The monotonic seq token invalidates an
|
||
in-flight load the moment a newer one starts: only the newest load
|
||
may touch the DOM after its await. */
|
||
let loadSeq = 0;
|
||
async function loadDocs() {
|
||
const my = ++loadSeq;
|
||
tbody.replaceChildren();
|
||
let r;
|
||
try {
|
||
r = await fetch("/api/docs");
|
||
} catch {
|
||
if (my === loadSeq) showEmpty();
|
||
return;
|
||
}
|
||
if (!r.ok) {
|
||
if (my === loadSeq) showEmpty();
|
||
return;
|
||
}
|
||
const { documents } = await r.json();
|
||
if (my !== loadSeq) return; // a newer load owns the tbody now
|
||
if (!documents.length) {
|
||
showEmpty();
|
||
return;
|
||
}
|
||
|
||
let totalChunks = 0;
|
||
let last = "";
|
||
for (const d of documents) {
|
||
totalChunks += d.chunks;
|
||
if (d.indexed_at > last) last = d.indexed_at;
|
||
tbody.appendChild(makeRow(d));
|
||
}
|
||
statDocs.textContent = String(documents.length);
|
||
statChunks.textContent = String(totalChunks);
|
||
statLast.textContent = last ? fmtDate(last) : "–";
|
||
emptyEl.hidden = true;
|
||
tableWrap.hidden = false;
|
||
}
|
||
|
||
function makeRow(d) {
|
||
const tr = document.createElement("tr");
|
||
|
||
const sourceTd = document.createElement("td");
|
||
sourceTd.textContent = d.source; // document-derived text — never innerHTML
|
||
tr.appendChild(sourceTd);
|
||
|
||
// Path cell: a link to the document (phase 10), full path as the
|
||
// accessible/hover name (the column is ellipsized). Phase 26: the
|
||
// left-click opens the same-page modal — no new tab (document-modal.js);
|
||
// the href stays as the no-JS / context-menu escape hatch.
|
||
const pathTd = document.createElement("td");
|
||
pathTd.title = d.path; // full path on hover (column is ellipsized)
|
||
const link = document.createElement("a");
|
||
link.className = "doc-link";
|
||
link.href = documentUrl(d.source, d.path);
|
||
link.addEventListener("click", (e) => {
|
||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||
e.stopPropagation();
|
||
openDocumentModal(d.source, d.path, link);
|
||
});
|
||
link.title = d.path; // full path as the link's hover/accessible name
|
||
link.textContent = d.path;
|
||
pathTd.appendChild(link);
|
||
tr.appendChild(pathTd);
|
||
|
||
for (const value of [d.title, String(d.chunks), fmtDate(d.indexed_at)]) {
|
||
const td = document.createElement("td");
|
||
td.textContent = value;
|
||
tr.appendChild(td);
|
||
}
|
||
return tr;
|
||
}
|
||
|
||
function showEmpty() {
|
||
statDocs.textContent = "0";
|
||
statChunks.textContent = "0";
|
||
statLast.textContent = "–";
|
||
emptyEl.hidden = false;
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
}
|
||
|
||
|
||
/* ---------- 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
|
||
* admin gate reads fetchIsAdmin() — the SAME cached whoami promise
|
||
* header.js exports (zero extra requests): the sync button joins
|
||
* the admin reveal on that one whoami (no extra fetch), and the
|
||
* anonymous branch gates the catalog in / out with NO /api/docs
|
||
* request at all (the Sources-page soft rule, unchanged). */
|
||
const admin = await fetchIsAdmin();
|
||
if (syncBtn) syncBtn.hidden = !admin; // admin-only: ship-hidden, revealed on the same whoami
|
||
if (!admin) {
|
||
// Anonymous: gate in, catalog out, and no /api/docs request at all.
|
||
if (statCards) statCards.hidden = true;
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
if (emptyEl) emptyEl.hidden = true;
|
||
if (gateEl) gateEl.hidden = false;
|
||
return;
|
||
}
|
||
if (gateEl) gateEl.hidden = true;
|
||
/* 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-load the catalog then (loadDocs is re-entrant).
|
||
Armed ONLY here, after the whoami gate passed: anonymous shows
|
||
the gate and must never fetch /api/docs (the phase-16 soft
|
||
rule the story E2E pins). */
|
||
root.addEventListener("bor:view-refresh", () => loadDocs());
|
||
loadDocs();
|
||
}
|