All completion criteria verified — everything is green, no defects found. Final report: ## Phase 97 final verification pass — ALL GREEN **Verified (no code changes needed):** - `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css` - `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate - Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved **Gates (exact commands):** - `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓) - `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors** - `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation - 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview` **Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run) **Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
1194 lines
51 KiB
JavaScript
1194 lines
51 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 the catalog load, which is re-entrant:
|
||
* a re-load clears the row containers' rows BEFORE filling them (the
|
||
* History pattern from task 01 — phase 97 moved the clear from the
|
||
* fetch top into the render, and from the one tbody to both), so a
|
||
* refresh from a populated level into a sparser one replaces the rows
|
||
* (no ghost rows). #sources-empty lives OUTSIDE the tbody (a
|
||
* .empty-state div, not a row), so the 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 the catalog (the phase-16 soft rule).
|
||
*
|
||
* Phase 97 (task 04) — the catalog becomes the DRILL-DOWN TREE the
|
||
* agent's `ls` sees (the phase-94 concept, one end to end). The load
|
||
* is now `loadTree()`: ONE fetch of GET /api/docs/tree returns the
|
||
* FULL recursive tree (sources → folders → files, each folder carrying
|
||
* its STORED description — the rows the agent reads), and the view
|
||
* drills CLIENT-SIDE from that single fetch — zero per-level fetches,
|
||
* no URL change (the navigation is a re-render of the already-fetched
|
||
* tree).
|
||
*
|
||
* • the drill state is module-scoped: `current` —
|
||
* { source: null, folder: null } = the top level (the rows ARE the
|
||
* sources themselves — the ls() equivalence: name, recursive count,
|
||
* the (source, "") description), `folder: ""` = the source root,
|
||
* `folder: "one/two"` = the nested folder. The last fetched tree
|
||
* lives in the module-scoped `kbTree`.
|
||
* • `renderLevel()` from `current` + `kbTree`: #kb-crumb (hidden at
|
||
* the top — one link per ancestor: the top level, the source, then
|
||
* the folder chain, the last segment an aria-current span), #kb-level
|
||
* (the current level's STORED description — title = the full
|
||
* source-relative path; HIDDEN when none is stored — the ls rule:
|
||
* count only, no placeholder), #folders-table = the level's direct
|
||
* subfolders (top level: the sources), #docs-table = the level's
|
||
* DIRECT files only — makeRow UNCHANGED (the path link still opens
|
||
* the same-page modal; the no-JS href escape hatch intact), hidden
|
||
* when the level has none (at the top level it is ALWAYS hidden —
|
||
* files are seen per source, as with ls(source)).
|
||
* • the stat cards walk the WHOLE tree (document count, chunks sum,
|
||
* max indexed_at) — the values identical to the former flat walk.
|
||
* • the empty state (#sources-empty) is now the "zero SOURCES"
|
||
* semantic (nothing registered, nothing indexed) — the deliberate
|
||
* phase-97 change: a registered 0-document source renders its row
|
||
* (`0` documents) instead (the ls invariant — the agent lists it
|
||
* too). A failed tree fetch renders that same no-data state (the
|
||
* former flat-load failure behavior, unchanged in kind).
|
||
* • Phase 77/79 carry over: the monotonic loadSeq race token (only
|
||
* the newest load may touch the DOM after its await) and the
|
||
* re-entrant render — renderLevel() clears BOTH row containers
|
||
* BEFORE filling them, so a refresh from a populated level into a
|
||
* sparser one leaves no ghost rows. NEVER-STALE (PLAN §7.4): after
|
||
* a re-fetch (re-show, sync success, upload success), if the current
|
||
* location no longer exists in the NEW tree (source unregistered/
|
||
* pruned, folder vanished), `current` RESETS to the top level BEFORE
|
||
* rendering — no stale breadcrumb, no stale block.
|
||
* • the refresh wirings move with the rename: `loadTree()` at the
|
||
* boot load, the bor:view-refresh listener, applySyncSuccess, and
|
||
* the upload-success branch of startSyncPolling — the anonymous
|
||
* branch still NEVER fetches (no /api/docs/tree request at all).
|
||
* • the sync button / poll / label / banner / error-modal machinery
|
||
* and the document-modal wiring are UNTOUCHED (the sync section
|
||
* above is byte-identical save its two catalog-refresh call
|
||
* sites, which now call loadTree() — the phase-97 rename).
|
||
*
|
||
* Phase 97 (task 05) — the folder-description EDITOR (the phase-57
|
||
* affordance, mirrored). The owner edits (or clears) any directory's
|
||
* stored description with the EXACT file-summary interaction: Edit →
|
||
* inline textarea (prefilled via .value) → Save / Cancel → a
|
||
* role=status live-region status.
|
||
*
|
||
* • ONE shared function, wireDescriptionEdit(), drives BOTH
|
||
* surfaces: the static #kb-level Edit button (the current level's
|
||
* description — the block ships the <h2> + a .kb-level-body holding
|
||
* the <p> + the Edit button) and the ALWAYS-present Edit button in
|
||
* every source/folder row's Description cell (makeDescCell builds
|
||
* it inside makeSourceRow / makeFolderRow — a description can be
|
||
* CREATED where none is stored: a < 2-document folder, the
|
||
* generator's fail-soft miss — the editor opens prefilled with the
|
||
* empty string). NO whoami gate in the view: the RAG view is
|
||
* admin-only already (the phase-16 gate) and the endpoint's
|
||
* require_admin is the API-level gate.
|
||
* • Save → PATCH /api/folders/summary with { source, folder_path,
|
||
* summary } — folder_path "" for the source root, the
|
||
* source-relative folder path otherwise (both known from the
|
||
* target: the row's node, or `current` for the level). 200 →
|
||
* re-render the description text (textContent ONLY — the XSS
|
||
* contract) in the surface where the edit happened + status
|
||
* "Description updated."; an empty save (the server echoes
|
||
* summary null) → the text goes away (level block hidden / row
|
||
* cell emptied) + "Description cleared."; the in-memory kbTree
|
||
* node's summary is updated IN PLACE (no re-fetch — the tree state
|
||
* stays coherent; the re-fetch is the safety net). Failure
|
||
* (non-2xx / network) → neutral retry copy (the phase-55
|
||
* convention), the editor stays open with the user's text, the
|
||
* stored text untouched. Cancel → restore the text node.
|
||
* • the level block is PERSISTENT (reused across levels as you
|
||
* drill), so wireDescriptionEdit takes a getTarget() getter (a
|
||
* row's is a constant) and returns a handle whose reset() tears
|
||
* down an open editor before every re-render (PLAN §7.4 — a
|
||
* navigate-away or refresh never leaves a stale open editor).
|
||
* • every editor part is static createElement; the description text
|
||
* is a text node (textContent / .value) — this module never builds
|
||
* HTML from document-derived data (the house rule, unchanged).
|
||
*/
|
||
|
||
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);
|
||
}
|
||
|
||
/* ---------- folder-description editing (phase 97, task 05) ----------
|
||
* The phase-57 edit affordance, mirrored for the RAG view's folder
|
||
* descriptions (the owner edits/clears any directory's stored
|
||
* description exactly like a file summary): Edit → inline textarea
|
||
* (prefilled via .value — the XSS contract) → Save / Cancel → a
|
||
* role=status live-region status, wired to PATCH /api/folders/summary.
|
||
* Every part is static createElement; the description text is a text
|
||
* node (textContent / .value) — this module never builds HTML from
|
||
* document-derived data (the house rule). */
|
||
function mkBtn(cls, label) {
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = cls;
|
||
b.textContent = label;
|
||
return b;
|
||
}
|
||
|
||
/* The shared description editor. `container` holds ONLY the
|
||
* description UI (the row's <td>, or the level block's .kb-level-body)
|
||
* — the swap is a bare replaceChildren on it, so any persistent
|
||
* sibling (the level's <h2>) is untouched. `getTarget()` returns the
|
||
* current { node, source, folder } at open/save time: a row's is a
|
||
* constant (the row's node), the level's reads `current` +
|
||
* currentLevelNode() (the block is reused across levels). `node` is
|
||
* the in-memory kbTree node: its .summary is read on open (prefill)
|
||
* and updated IN PLACE on success (no re-fetch — the tree state stays
|
||
* coherent; the re-fetch is the safety net). `folder` is "" for the
|
||
* source root, the source-relative folder path otherwise. `onCleared`
|
||
* (optional) runs after a successful clear — the level block hides
|
||
* itself (the ls rule); a row cell just goes empty (the always-present
|
||
* button stays). Returns a handle whose reset() tears down an OPEN
|
||
* editor before a re-render (PLAN §7.4 — never a stale open editor). */
|
||
function wireDescriptionEdit({ editBtn, textEl, container, getTarget, onCleared }) {
|
||
const actions = document.createElement("div");
|
||
actions.className = "kb-summary-actions";
|
||
const saveBtn = mkBtn("kb-summary-save", "Save");
|
||
const cancelBtn = mkBtn("kb-summary-cancel", "Cancel");
|
||
actions.append(saveBtn, cancelBtn);
|
||
const status = document.createElement("p");
|
||
status.className = "kb-summary-status";
|
||
status.setAttribute("role", "status");
|
||
status.setAttribute("aria-live", "polite");
|
||
let editor = null;
|
||
let isOpen = false;
|
||
|
||
/* Back to the display state: the text element re-rendered from the
|
||
* node (the CURRENT stored description, textContent only), the Edit
|
||
* button available again. A cleared node empties the text and, for
|
||
* the level block, hides the whole block via onCleared. */
|
||
function closeEditor(message) {
|
||
const target = getTarget();
|
||
const node = target ? target.node : null;
|
||
const value = node && typeof node.summary === "string" ? node.summary : "";
|
||
textEl.textContent = value; // text node — the CURRENT stored description
|
||
editBtn.hidden = false;
|
||
status.textContent = message;
|
||
container.replaceChildren(textEl, editBtn, status);
|
||
isOpen = false;
|
||
if (value.trim() === "" && onCleared) onCleared(); // level: hide the block
|
||
else editBtn.focus(); // return focus to the opener
|
||
}
|
||
|
||
function openEditor() {
|
||
if (isOpen) return;
|
||
const target = getTarget();
|
||
if (!target) return;
|
||
editor = document.createElement("textarea");
|
||
editor.className = "kb-summary-editor";
|
||
editor.value = typeof target.node.summary === "string" ? target.node.summary : ""; // .value, never innerHTML
|
||
status.textContent = "";
|
||
editBtn.hidden = true;
|
||
container.replaceChildren(editor, actions, status);
|
||
editor.focus();
|
||
isOpen = true;
|
||
}
|
||
|
||
async function saveDescription() {
|
||
const target = getTarget();
|
||
if (!target) return;
|
||
const { node, source, folder } = target;
|
||
const value = editor.value;
|
||
saveBtn.disabled = true; // one PATCH at a time (never stale)
|
||
status.textContent = "";
|
||
try {
|
||
const res = await fetch("/api/folders/summary", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ source, folder_path: folder, summary: value }),
|
||
});
|
||
if (!res.ok) {
|
||
// Neutral retry copy (phase-55) — the editor stays OPEN with the
|
||
// user's text (no swap back, the stored text is untouched).
|
||
status.textContent = "Couldn't save the description — try again.";
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
node.summary = data.summary; // in-place kbTree update (no re-fetch)
|
||
closeEditor(data.summary === null ? "Description cleared." : "Description updated.");
|
||
} catch {
|
||
// Network failure: the reachable? copy; the editor stays open.
|
||
status.textContent = "Couldn't save the description — is the app reachable?";
|
||
} finally {
|
||
saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
editBtn.addEventListener("click", openEditor);
|
||
saveBtn.addEventListener("click", () => {
|
||
void saveDescription();
|
||
});
|
||
cancelBtn.addEventListener("click", () => closeEditor(""));
|
||
|
||
return {
|
||
/* Tear down an OPEN editor with NO message and WITHOUT re-rendering
|
||
* the text from the (possibly stale) node — the surface is about to
|
||
* re-render for a DIFFERENT level (navigation / refresh): a stale
|
||
* open editor must never survive a re-render (PLAN §7.4). A closed
|
||
* editor is a no-op. */
|
||
reset() {
|
||
if (!isOpen) return;
|
||
editor = null;
|
||
saveBtn.disabled = false;
|
||
status.textContent = "";
|
||
editBtn.hidden = false;
|
||
container.replaceChildren(textEl, editBtn);
|
||
isOpen = false;
|
||
},
|
||
};
|
||
}
|
||
|
||
/* A source/folder row's Description cell (task 05): the stored
|
||
* description text (a text node, textContent only) + the ALWAYS-present
|
||
* Edit button (`.kb-summary-edit`) — a description can be CREATED where
|
||
* none is stored (a < 2-document folder, the generator's fail-soft
|
||
* miss), so the button is added unconditionally (the view is
|
||
* admin-only already — the endpoint's require_admin is the API gate).
|
||
* The shared editor is wired with a CONSTANT target (the row's node).
|
||
* `label` is the human name for the button's aria-label. */
|
||
function makeDescCell(node, source, folder, label) {
|
||
const td = document.createElement("td");
|
||
const text = document.createElement("span");
|
||
text.textContent = (node && node.summary) || ""; // text node — never innerHTML
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "kb-summary-edit";
|
||
btn.textContent = "Edit";
|
||
btn.setAttribute("aria-label", `Edit description: ${label}`);
|
||
td.append(text, btn);
|
||
wireDescriptionEdit({
|
||
editBtn: btn,
|
||
textEl: text,
|
||
container: td,
|
||
getTarget: () => ({ node, source, folder }),
|
||
});
|
||
return td;
|
||
}
|
||
|
||
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
|
||
* (loadTree — 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 loadTree() 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.
|
||
loadTree();
|
||
}
|
||
|
||
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" });
|
||
loadTree();
|
||
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 loadTree() 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");
|
||
/* Phase 97 (task 04): the new #folders-wrap ALSO carries the shared
|
||
* .table-wrap card class — the FILE table's wrap is therefore looked
|
||
* up through its own table, not the class (a class lookup would hit
|
||
* #folders-wrap first in document order). */
|
||
const tableWrap = root.querySelector("#docs-table").parentElement;
|
||
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");
|
||
const crumbEl = root.querySelector("#kb-crumb");
|
||
const levelEl = root.querySelector("#kb-level");
|
||
const levelTitleEl = root.querySelector("#kb-level-title");
|
||
const levelSummaryEl = root.querySelector("#kb-level-summary");
|
||
const foldersWrap = root.querySelector("#folders-wrap");
|
||
const foldersTbody = root.querySelector("#folders-tbody");
|
||
/* Phase 97 (task 05): the level block's static Edit button + the
|
||
* .kb-level-body that holds the description UI (the <p> + the
|
||
* button) — the shared editor swaps INSIDE the body, leaving the
|
||
* block's <h2> untouched. */
|
||
const levelEditBtn = root.querySelector("#kb-level-edit");
|
||
const levelBody = levelEl ? levelEl.querySelector(".kb-level-body") : null;
|
||
|
||
/* 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 97 (task 04): the drill-down tree (the module docstring
|
||
carries the full contract). `kbTree` = the last fetched tree;
|
||
`current` = the drill state ({ null, null } = top, `folder: ""`
|
||
= the source root). Phase 79 carries over: the monotonic loadSeq
|
||
race token invalidates an in-flight load the moment a newer one
|
||
starts — only the newest load may touch the DOM after its await
|
||
(the boot re-attach (applySyncSuccess → loadTree) and the boot-time
|
||
loadTree interleave exactly as the flat load once did). Re-entrancy
|
||
moves from the fetch top into the render: renderLevel() clears
|
||
BOTH row containers BEFORE filling them (the phase-77 History
|
||
pattern, extended to the second container), so a refresh from a
|
||
populated level into a sparser one leaves no ghost rows. */
|
||
let kbTree = { sources: [] };
|
||
let current = { source: null, folder: null };
|
||
let loadSeq = 0;
|
||
let levelEditor = null; // phase 97 (task 05): the level editor handle
|
||
|
||
async function loadTree() {
|
||
const my = ++loadSeq;
|
||
let r;
|
||
try {
|
||
r = await fetch("/api/docs/tree");
|
||
} catch {
|
||
if (my === loadSeq) renderEmpty();
|
||
return;
|
||
}
|
||
if (!r.ok) {
|
||
if (my === loadSeq) renderEmpty();
|
||
return;
|
||
}
|
||
const tree = await r.json();
|
||
if (my !== loadSeq) return; // a newer load owns the DOM now
|
||
kbTree = tree && Array.isArray(tree.sources) ? tree : { sources: [] };
|
||
resetVanishedLocation();
|
||
renderLevel();
|
||
}
|
||
|
||
/* Never-stale (PLAN §7.4, phase 97): after a re-fetch, the drilled
|
||
* location may no longer exist in the NEW tree (the source was
|
||
* unregistered/pruned, the folder vanished) — reset to the top level
|
||
* BEFORE rendering: no stale breadcrumb, no stale block. */
|
||
function resetVanishedLocation() {
|
||
if (current.source === null) return;
|
||
const src = kbTree.sources.find((s) => s.name === current.source);
|
||
if (!src) {
|
||
current = { source: null, folder: null };
|
||
return;
|
||
}
|
||
if (current.folder !== null && !folderExistsIn(src, current.folder)) {
|
||
current = { source: null, folder: null };
|
||
}
|
||
}
|
||
|
||
/* The phase-94 existence rule, mirrored client-side over the tree's
|
||
* file paths: a folder exists under the source iff some of the
|
||
* source's indexed paths starts with `folder + "/"` (the source root
|
||
* — `""` — always exists). */
|
||
function folderExistsIn(src, folderPath) {
|
||
if (folderPath === "") return true;
|
||
const paths = [];
|
||
const collect = (node) => {
|
||
for (const child of node.children || []) {
|
||
if (child.kind === "file") paths.push(child.path);
|
||
else collect(child);
|
||
}
|
||
};
|
||
collect(src);
|
||
return paths.some((p) => p.startsWith(folderPath + "/"));
|
||
}
|
||
|
||
/* The node for `current`: the source node at the root, or the folder
|
||
* node found by walking the tree with the folder's cumulative
|
||
* source-relative path (the builder's folder paths are
|
||
* source-relative, so `one/two` resolves one level at a time). */
|
||
function currentLevelNode() {
|
||
const src = kbTree.sources.find((s) => s.name === current.source);
|
||
if (!src) return null;
|
||
if (current.folder === null || current.folder === "") return src;
|
||
let node = src;
|
||
let acc = "";
|
||
for (const part of current.folder.split("/")) {
|
||
acc = acc ? acc + "/" + part : part;
|
||
const next = (node.children || []).find(
|
||
(c) => c.kind === "folder" && c.path === acc
|
||
);
|
||
if (!next) return null;
|
||
node = next;
|
||
}
|
||
return node;
|
||
}
|
||
|
||
/* The KB-wide stat cards (phase 97): walk the WHOLE tree — document
|
||
* count, chunks sum, max indexed_at — the values identical to the
|
||
* former flat /api/docs walk (the same documents, one level deeper).
|
||
* fmtDate reuse for the max. */
|
||
function treeStats() {
|
||
let docs = 0;
|
||
let totalChunks = 0;
|
||
let last = "";
|
||
const walk = (node) => {
|
||
for (const child of node.children || []) {
|
||
if (child.kind === "file") {
|
||
docs += 1;
|
||
totalChunks += child.chunks;
|
||
if (child.indexed_at > last) last = child.indexed_at;
|
||
} else {
|
||
walk(child);
|
||
}
|
||
}
|
||
};
|
||
for (const s of kbTree.sources) walk(s);
|
||
return { docs, totalChunks, last };
|
||
}
|
||
|
||
/* The breadcrumb (phase 97): hidden at the top level; when drilled
|
||
* in, one link per ancestor — the top level (back to the sources
|
||
* list), the source, then the folder chain — the LAST segment a
|
||
* span with aria-current="page". Client-side only: no fetch, no URL
|
||
* change (the navigation is a re-render of the fetched tree). */
|
||
function renderCrumb() {
|
||
if (!crumbEl) return;
|
||
crumbEl.replaceChildren();
|
||
if (current.source === null) {
|
||
crumbEl.hidden = true;
|
||
return;
|
||
}
|
||
crumbEl.hidden = false;
|
||
let appended = false;
|
||
const append = (el) => {
|
||
if (appended) {
|
||
const sep = document.createElement("span");
|
||
sep.className = "kb-crumb-sep";
|
||
sep.setAttribute("aria-hidden", "true");
|
||
sep.textContent = "/";
|
||
crumbEl.appendChild(sep);
|
||
}
|
||
crumbEl.appendChild(el);
|
||
appended = true;
|
||
};
|
||
append(crumbSegment("Knowledge base", { source: null, folder: null }));
|
||
if (current.folder === null || current.folder === "") {
|
||
append(crumbCurrent(current.source));
|
||
} else {
|
||
append(crumbSegment(current.source, { source: current.source, folder: "" }));
|
||
let acc = "";
|
||
const parts = current.folder.split("/");
|
||
parts.forEach((part, i) => {
|
||
acc = acc ? acc + "/" + part : part;
|
||
if (i === parts.length - 1) append(crumbCurrent(part));
|
||
else append(crumbSegment(part, { source: current.source, folder: acc }));
|
||
});
|
||
}
|
||
}
|
||
|
||
function crumbSegment(label, target) {
|
||
const a = document.createElement("a");
|
||
a.className = "kb-crumb-link";
|
||
a.href = "#"; // client-side navigation only — no URL change
|
||
a.textContent = label; // document-derived text — never innerHTML
|
||
a.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
goTo(target);
|
||
});
|
||
return a;
|
||
}
|
||
|
||
function crumbCurrent(label) {
|
||
const span = document.createElement("span");
|
||
span.className = "kb-crumb-current";
|
||
span.setAttribute("aria-current", "page");
|
||
span.textContent = label; // document-derived text — never innerHTML
|
||
return span;
|
||
}
|
||
|
||
/* The drill navigation (client-side, no fetch, no URL change). */
|
||
function goTo(target) {
|
||
current = { source: target.source, folder: target.folder };
|
||
renderLevel();
|
||
}
|
||
|
||
/* ONE table for every level (phase 97): at the TOP level the rows
|
||
* are the SOURCES themselves (the ls() equivalence — name, recursive
|
||
* count, the stored (source, "") description). textContent only. */
|
||
function makeSourceRow(s) {
|
||
const tr = document.createElement("tr");
|
||
const nameTd = document.createElement("td");
|
||
const link = document.createElement("a");
|
||
link.className = "folder-link";
|
||
link.href = "#"; // client-side drill — no URL change
|
||
link.title = s.name; // hover name (the cell may ellipsize)
|
||
link.textContent = s.name; // document-derived text — never innerHTML
|
||
link.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
goTo({ source: s.name, folder: "" });
|
||
});
|
||
nameTd.appendChild(link);
|
||
tr.appendChild(nameTd);
|
||
const countTd = document.createElement("td");
|
||
countTd.textContent = String(s.documents);
|
||
tr.appendChild(countTd);
|
||
tr.appendChild(makeDescCell(s, s.name, "", s.name)); // Description + ALWAYS-present Edit (task 05)
|
||
return tr;
|
||
}
|
||
|
||
/* A level's subfolder row: the folder's LAST path segment as the
|
||
* label (the full source-relative path rides the title — the cell
|
||
* ellipsizes), the recursive count, the stored description (AI or
|
||
* manual — any row) or an empty cell. textContent only. */
|
||
function makeFolderRow(f) {
|
||
const tr = document.createElement("tr");
|
||
const nameTd = document.createElement("td");
|
||
const link = document.createElement("a");
|
||
link.className = "folder-link";
|
||
link.href = "#"; // client-side drill — no URL change
|
||
link.title = current.source + "/" + f.path; // full path on hover
|
||
link.textContent = f.path.split("/").pop(); // never innerHTML
|
||
link.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
goTo({ source: current.source, folder: f.path });
|
||
});
|
||
nameTd.appendChild(link);
|
||
tr.appendChild(nameTd);
|
||
const countTd = document.createElement("td");
|
||
countTd.textContent = String(f.documents);
|
||
tr.appendChild(countTd);
|
||
tr.appendChild(
|
||
makeDescCell(f, current.source, f.path, current.source + "/" + f.path)
|
||
); // Description + ALWAYS-present Edit (task 05)
|
||
return tr;
|
||
}
|
||
|
||
/* Render the CURRENT level from `current` + `kbTree` (phase 97).
|
||
* Clears BOTH row containers FIRST (re-entrancy — no ghost rows),
|
||
* then: zero sources → the no-data state; top level → the source
|
||
* rows (level block + file table hidden); inside a source/folder →
|
||
* the breadcrumb, the level block (the level's stored description —
|
||
* hidden when none is stored, the ls rule), the direct subfolders,
|
||
* and the direct files (makeRow, unchanged — the file node carries
|
||
* no source, the row object restores the flat shape makeRow reads). */
|
||
function renderLevel() {
|
||
if (foldersTbody) foldersTbody.replaceChildren();
|
||
if (tbody) tbody.replaceChildren();
|
||
if (levelEditor) levelEditor.reset(); // a re-render never keeps a stale open editor (§7.4)
|
||
|
||
if (!kbTree.sources.length) {
|
||
renderEmpty();
|
||
return;
|
||
}
|
||
emptyEl.hidden = true;
|
||
|
||
const st = treeStats();
|
||
statDocs.textContent = String(st.docs);
|
||
statChunks.textContent = String(st.totalChunks);
|
||
statLast.textContent = st.last ? fmtDate(st.last) : "–";
|
||
|
||
renderCrumb();
|
||
|
||
if (current.source === null) {
|
||
// Top level: the level block is hidden (nothing above the
|
||
// sources); the folders table lists the SOURCES themselves and
|
||
// the file table is ALWAYS hidden (files are seen per source,
|
||
// as with ls(source)).
|
||
levelEl.hidden = true;
|
||
for (const s of kbTree.sources) foldersTbody.appendChild(makeSourceRow(s));
|
||
foldersWrap.hidden = false;
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
return;
|
||
}
|
||
|
||
const node = currentLevelNode();
|
||
if (node === null) {
|
||
// Defensive: the reset above guarantees the level exists in the
|
||
// tree this render reads — a null means the tree changed under
|
||
// us, in which case the top level is the honest view.
|
||
current = { source: null, folder: null };
|
||
renderLevel();
|
||
return;
|
||
}
|
||
|
||
// The level block: the current level's STORED description (source:
|
||
// the (source, "") row; folder: its row) — hidden when none is
|
||
// stored (the ls rule: count only, no placeholder). Title = the
|
||
// full source-relative path (e.g. `alpha/two`).
|
||
if (node.summary) {
|
||
levelTitleEl.textContent = current.folder
|
||
? current.source + "/" + current.folder
|
||
: current.source;
|
||
levelSummaryEl.textContent = node.summary;
|
||
levelEl.hidden = false;
|
||
} else {
|
||
levelEl.hidden = true;
|
||
}
|
||
|
||
const children = node.children || [];
|
||
const subfolders = children.filter((c) => c.kind === "folder");
|
||
const files = children.filter((c) => c.kind === "file");
|
||
for (const f of subfolders) foldersTbody.appendChild(makeFolderRow(f));
|
||
foldersWrap.hidden = subfolders.length === 0;
|
||
for (const f of files) {
|
||
tbody.appendChild(
|
||
makeRow({
|
||
source: current.source,
|
||
path: f.path,
|
||
title: f.title,
|
||
chunks: f.chunks,
|
||
indexed_at: f.indexed_at,
|
||
})
|
||
);
|
||
}
|
||
if (tableWrap) tableWrap.hidden = files.length === 0;
|
||
}
|
||
|
||
/* The no-data state (phase 97): zero sources (nothing registered,
|
||
* nothing indexed) OR a failed tree fetch (the former showEmpty
|
||
* failure behavior, unchanged in kind) — every catalog surface
|
||
* hidden, the stat cards read zero. A registered 0-document source
|
||
* does NOT land here: it renders its `0 documents` row (the ls
|
||
* invariant — the deliberate semantic change, module docstring). */
|
||
function renderEmpty() {
|
||
if (levelEditor) levelEditor.reset(); // a re-render never keeps a stale open editor (§7.4)
|
||
statDocs.textContent = "0";
|
||
statChunks.textContent = "0";
|
||
statLast.textContent = "–";
|
||
if (crumbEl) crumbEl.hidden = true;
|
||
levelEl.hidden = true;
|
||
if (foldersTbody) foldersTbody.replaceChildren();
|
||
if (tbody) tbody.replaceChildren();
|
||
if (foldersWrap) foldersWrap.hidden = true;
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
emptyEl.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;
|
||
}
|
||
|
||
|
||
|
||
/* Phase 97 (task 05): wire the LEVEL block's editor (the static
|
||
* #kb-level-edit button). The level block is PERSISTENT — reused for
|
||
* whichever level is current — so the target is a GETTER (a row's is
|
||
* a constant): it reads `current` + currentLevelNode() at open/save
|
||
* time. onCleared hides the whole block (the ls rule: no description
|
||
* → no block). The handle's reset() runs on every re-render so a
|
||
* navigate-away / refresh never leaves a stale open editor (§7.4). */
|
||
if (levelEditBtn && levelBody) {
|
||
levelEditor = wireDescriptionEdit({
|
||
editBtn: levelEditBtn,
|
||
textEl: levelSummaryEl,
|
||
container: levelBody,
|
||
getTarget: () => {
|
||
const node = currentLevelNode();
|
||
return node ? { node, source: current.source, folder: current.folder } : null;
|
||
},
|
||
onCleared: () => {
|
||
// The phase-57 announcement beat: keep the block visible a short
|
||
// beat so the role=status "Description cleared." is still
|
||
// readable, THEN hide it (the ls rule: no description → no
|
||
// block) — GUARDED: if a newer level is current by then (a
|
||
// navigate-away first) or its node has a description again (a
|
||
// re-create within the beat), the hide is a no-op.
|
||
setTimeout(() => {
|
||
const n = currentLevelNode();
|
||
if (n && !n.summary) levelEl.hidden = true;
|
||
}, 2000);
|
||
},
|
||
});
|
||
}
|
||
|
||
/* ---------- 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/tree request at all (the phase-16 soft rule — the
|
||
* tree IS the catalog; phase 97). */
|
||
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/tree request
|
||
// at all — the tree surfaces ship hidden and never fill.
|
||
if (statCards) statCards.hidden = true;
|
||
if (foldersWrap) foldersWrap.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) + phase 97 (task 04): a user-initiated
|
||
re-show of this already-mounted view makes the router dispatch
|
||
bor:view-refresh on the section — re-load the catalog tree then
|
||
(loadTree is race-tokened: only the newest load touches the
|
||
DOM). Armed ONLY here, after the whoami gate passed: anonymous
|
||
shows the gate and must never fetch /api/docs/tree (the
|
||
phase-16 soft rule the story E2E pins). */
|
||
root.addEventListener("bor:view-refresh", () => loadTree());
|
||
loadTree();
|
||
}
|