All verification complete. Final report: **Phase 98 — Sync summary visibility: final verification pass** (all 5 tasks already complete; implementation verified against the design, no defects found, no code changes needed) - **Implementation checked:** `SyncStatus` phase machine (4 new keys, terminal-keep counts), `on_progress` hook in `generate_folder_summaries`, `summary_pending` on `KbTreeSource`/`KbTreeFolder` + D3 rule in `build_kb_tree`, phase-aware sync labels + pending UI in `sources.js`, `.kb-summary-pending` CSS — all match decisions D1–D5. - **Unit + integration:** `uv run pytest` → 2184 tests, 0 failed/errors (exit 0) - **Coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (criterion >90% ✓; `app/api/sync.py` and `app/rag/folder_summaries.py` at 100%) - **Lint/types:** `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings - **Phase E2E (isolation):** `uv run pytest tests/e2e/test_sync_summary_visibility.py -v --no-cov` → **3 passed** (phase machine, live label, pending markers + gap-fill self-heal) - **Regression suites (each isolated, `--no-cov`):** test_kb_tree ✓, test_ls_tree_drilldown 3 ✓, test_sync_button 3 ✓, test_sync_upload_progress 4 ✓, test_oneshot_llm_retry 2 ✓, test_local_directory_sources 3 ✓ - **Completion criteria:** all 7 verified green — status phase fields + terminal semantics; `Writing KB overview…`/`Summarizing folders… (n/m)` labels (title + aria-live); pending set == `missing_folder_summaries` (integration cross-check pinned at `test_docs_api.py:428`); CLI/`ls` byte-identity (no changes to those paths, pins green); suite/coverage/lint gates; dedicated + regression E2E. Commit left to the harness per protocol (no `git add`/`commit` run). - **Decisions/deviations:** none — no fixes were required this pass. - **Next pending phase:** `99_kb_tree_table_and_back_nav`.
1371 lines
61 KiB
JavaScript
1371 lines
61 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) — and, since
|
||
* Phase 98 (task 04), its summary_pending flag is cleared IN
|
||
* PLACE on the same success path (a created/updated description
|
||
* is no longer pending — the D4 marker clears where the edit
|
||
* happened, no re-fetch). 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).
|
||
*
|
||
* Phase 98 (task 02) — the sync button's phase-aware labels: the
|
||
* post-import span of a sync (the KB overview + the long
|
||
* folder-summary span) no longer reads as a stuck file count. The
|
||
* SYNC job's running label is now phase-aware (fmtSyncPhaseLabel —
|
||
* the section header below carries the decision tree): "overview" →
|
||
* "Writing KB overview…", "summaries" → "Summarizing folders…
|
||
* <source/folder> (n/m)" (the folder part omitted while
|
||
* current_summary is null — the phase's first poll), anything else
|
||
* (the null prelude, "import") → today's "Syncing… <file> (n/m)"
|
||
* byte-identical. The UPLOAD job (its status has no phase) keeps the
|
||
* bare label. The untruncated label rides the button title (now set
|
||
* for EVERY running label, not just file labels) + #sync-result as
|
||
* before (A4).
|
||
*
|
||
* Phase 98 (task 04) — the "Summary pending" markers: the catalog
|
||
* tells the owner a summary is WAITING instead of showing an empty
|
||
* cell that reads as "missed". A source/folder node is
|
||
* summary_pending when its summary is due (recursive count >= 2 —
|
||
* the D3 rule, exactly the next sync's gap-fill set) but no stored
|
||
* description exists yet. The marker has TWO surfaces, both driven
|
||
* by the same node flag:
|
||
*
|
||
* • the row's Description cell (makeDescCell) — when the stored
|
||
* summary is empty AND node.summary_pending, the text span
|
||
* carries the class kb-summary-pending (the muted ink-soft pair,
|
||
* AA on --surface — text + color, never color alone; the row
|
||
* cell's font-size/line-height apply — no row-height change) +
|
||
* the text "Summary pending" + the D4 title, verbatim:
|
||
* "No stored description yet — the next sync will generate one."
|
||
* A stored summary
|
||
* shows the stored text (NEVER the marker); neither stored nor
|
||
* pending shows the empty cell (the ls rule, unchanged). The
|
||
* Edit button is UNCHANGED — always present (a manual save
|
||
* creates the row).
|
||
* • the level block (renderLevel) — #kb-level shows when the
|
||
* current level has a stored description (as today) OR is
|
||
* pending: title as today (the full source-relative path), and
|
||
* #kb-level-summary shows the stored text, or — when pending —
|
||
* the D4 pending note, verbatim:
|
||
* "No description stored yet — the next sync will generate one. (You can write one yourself.)"
|
||
* Neither stored nor pending stays hidden (the ls rule,
|
||
* unchanged). The block's
|
||
* Edit button (write one manually now) already ships in the
|
||
* static markup (phase 97, task 05).
|
||
* • the in-place clear: wireDescriptionEdit's success path sets
|
||
* node.summary_pending = false right after
|
||
* node.summary = data.summary — a created/updated description is
|
||
* no longer pending; the marker clears IN PLACE in the surface
|
||
* where the edit happened, no re-fetch (the re-fetch stays the
|
||
* safety net). closeEditor re-renders the display state from the
|
||
* node with the SAME three-state rule the surfaces use: the
|
||
* pending marker's muted class + tooltip CANNOT survive a save
|
||
* (a stale "next sync" tooltip under a just-created description),
|
||
* and a cancel restores the surface's pending display (each
|
||
* surface passes its pending copy via pendingText — the row's
|
||
* "Summary pending" marker, the level's D4 note — and its
|
||
* tooltip via pendingTitle, the row only).
|
||
*/
|
||
|
||
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,
|
||
pendingText,
|
||
pendingTitle,
|
||
}) {
|
||
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 display state re-rendered from the
|
||
* node with the SAME three-state rule the surfaces use (makeDescCell
|
||
* / renderLevel — textContent/class/title only, the house rule):
|
||
* a stored summary → the stored text with NO marker (the in-place
|
||
* clear — the success path cleared node.summary_pending, so the
|
||
* muted pending style + the stale "next sync" tooltip cannot survive
|
||
* a saved description); no stored text but PENDING → the surface's
|
||
* pending display (pendingText — the row's "Summary pending" marker
|
||
* + its D4 tooltip, the level's D4 note — a cancel restores exactly
|
||
* the pre-edit state, including the marker); neither → the empty
|
||
* text. The Edit button is 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 stored = node && typeof node.summary === "string" ? node.summary : "";
|
||
const pending = node !== null && stored === "" && node.summary_pending;
|
||
const value = pending && pendingText ? pendingText : stored;
|
||
textEl.className = pending ? "kb-summary-pending" : "";
|
||
if (pending) {
|
||
if (pendingTitle) textEl.title = pendingTitle;
|
||
} else {
|
||
textEl.removeAttribute("title");
|
||
}
|
||
textEl.textContent = value; // text node — the CURRENT display state
|
||
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)
|
||
node.summary_pending = false; // phase 98 (D4): a created/updated description is no longer pending — the marker clears in place (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.
|
||
* Phase 98 (task 04, D4): the cell has THREE states — a stored summary
|
||
* (the stored text, textContent only), PENDING (no stored summary but
|
||
* node.summary_pending — the marker: the kb-summary-pending class +
|
||
* the "Summary pending" text + the D4 title; text + color, never color
|
||
* alone), and the empty cell (neither — the ls rule, unchanged). The
|
||
* Edit button is UNCHANGED in all three (a manual save creates the row
|
||
* and clears the marker in place — the editor's success path). */
|
||
function makeDescCell(node, source, folder, label) {
|
||
const td = document.createElement("td");
|
||
const text = document.createElement("span");
|
||
if (node && node.summary) {
|
||
text.textContent = node.summary; // stored description — text node, never innerHTML
|
||
} else if (node && node.summary_pending) {
|
||
// Phase 98 (D4): the summary is due but not stored yet — the marker.
|
||
text.className = "kb-summary-pending";
|
||
text.textContent = "Summary pending";
|
||
text.title = "No stored description yet — the next sync will generate one.";
|
||
} else {
|
||
text.textContent = ""; // neither stored nor pending — the empty cell (the ls rule)
|
||
}
|
||
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 }),
|
||
// Phase 98 (D4): the row's pending display (closeEditor restores
|
||
// it on a cancel; a save clears the marker in place).
|
||
pendingText: "Summary pending",
|
||
pendingTitle: "No stored description yet — the next sync will generate one.",
|
||
});
|
||
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 → the phase-aware label (Phase 98 task 02, D2)
|
||
* — the null-phase prelude + "import" →
|
||
* "Syncing… <file> (n/m)" — bare "Syncing…"
|
||
* until the import's first file (clone/pull,
|
||
* A4, byte-identical); "overview" → "Writing KB
|
||
* overview…"; "summaries" → "Summarizing
|
||
* folders… <source/folder> (n/m)" (the folder
|
||
* part omitted while current_summary is null —
|
||
* the phase's first poll) — the post-import span
|
||
* where the file count sits still;
|
||
* 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) — or, in the post-import phases, the phase's own copy (Phase
|
||
* 98 task 02): CSS ellipsizes #sync-label; the full untruncated
|
||
* label also rides the button title (hover — set for EVERY running
|
||
* label, not just file labels) and #sync-result (the aria-live
|
||
* announcer — screen readers hear it). The load-time re-attach
|
||
* (initSyncButton) re-enters a RUNNING sync the same way — with
|
||
* whatever phase the status reports (a mid-summaries reload shows
|
||
* the summaries label — the never-stale contract) — and 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). Phase 98 (task 02): the
|
||
* SYNC job's post-import phases (overview / summaries) take their
|
||
* own label via fmtSyncPhaseLabel (below) — this builder keeps that
|
||
* prelude/import fall-through byte-identical and serves the UPLOAD
|
||
* job unchanged (its status has no phase — D2). */
|
||
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;
|
||
}
|
||
|
||
/* Phase 98 (task 02, D2): the SYNC job's phase-aware running label
|
||
* — the post-import span where the file count sits still (the
|
||
* owner's "the number pauses for a really long time"): "overview"
|
||
* → "Writing KB overview…"; "summaries" → "Summarizing folders…
|
||
* <source/folder> (n/m)" — the folder part is omitted while
|
||
* current_summary is null (the phase's first poll); anything else
|
||
* (the null prelude, "import") → the phase-64 fmtSyncLabel
|
||
* fall-through, byte-identical (same builder, same fields, same
|
||
* order). The UPLOAD job never passes a status (its endpoint has no
|
||
* phase — D2) and keeps the bare fmtSyncLabel label. The untruncated
|
||
* result rides the button title + #sync-result like every running
|
||
* label (A4). */
|
||
function fmtSyncPhaseLabel(status) {
|
||
if (status.phase === "overview") return "Writing KB overview…";
|
||
if (status.phase === "summaries") {
|
||
const folder = status.current_summary ? ` ${status.current_summary}` : "";
|
||
return `Summarizing folders…${folder} (${status.summaries_done}/${status.summaries_total})`;
|
||
}
|
||
// null phase (the prelude) or "import" — today's byte-identical
|
||
// sync label (the phase-64 contract, unchanged).
|
||
return fmtSyncLabel("sync", status.current_file, status.files_done, status.files_total);
|
||
}
|
||
|
||
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). Phase 98 (task 02, D2): the
|
||
* SYNC job passes the status object as `status` — its running label
|
||
* is phase-aware (fmtSyncPhaseLabel: "Writing KB overview…" /
|
||
* "Summarizing folders… <folder> (n/m)"); the UPLOAD job and the
|
||
* 202/409 click (no status yet) keep the bare fmtSyncLabel label.
|
||
* Same mechanics as before (disabled, aria-busy, spinning icon, no
|
||
* is-error) plus: the FULL untruncated label on the button title
|
||
* (set for EVERY running label — the phase-64 file-only rule is
|
||
* adjusted for the phase labels) and in #sync-result (the aria-live
|
||
* announcer reads it; CSS ellipsizes the button's label span only).
|
||
*/
|
||
function enterSyncRunningState(kind, currentFile, done, total, status) {
|
||
if (!syncBtn) return;
|
||
syncBtn.disabled = true;
|
||
syncBtn.setAttribute("aria-busy", "true");
|
||
const label =
|
||
kind === "sync" && status
|
||
? fmtSyncPhaseLabel(status)
|
||
: fmtSyncLabel(kind, currentFile, done, total);
|
||
syncBtn.title = label; // A4: the untruncated label rides the title, always
|
||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||
syncBtn.classList.remove("is-error");
|
||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||
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 phase-aware label (phase 98 task 02 — the
|
||
// null prelude + "import" keep the byte-identical file label,
|
||
// "overview" / "summaries" name themselves; the WHOLE status
|
||
// goes in for the phase fields).
|
||
if (syncStatus.state === "running") {
|
||
enterSyncRunningState(
|
||
"sync", syncStatus.current_file, syncStatus.files_done, syncStatus.files_total,
|
||
syncStatus
|
||
);
|
||
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). Phase 98 (task 02): a RUNNING sync re-enters
|
||
* with the WHOLE status — the label is phase-aware, so a mid-
|
||
* summaries reload re-enters with the summaries label (the
|
||
* never-stale contract); a RUNNING upload re-attach stays on the
|
||
* bare label (its status has no phase — D2). */
|
||
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,
|
||
status
|
||
);
|
||
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) — OR, since Phase 98
|
||
// (task 04, D4), the PENDING note when the level is summary_pending
|
||
// (the summary is due but not stored yet — the next sync's gap-fill
|
||
// will generate it, or the owner can write one now via the block's
|
||
// Edit button). Hidden only when NEITHER is stored nor pending
|
||
// (the ls rule: count only, no placeholder). Title = the full
|
||
// source-relative path (e.g. `alpha/two`).
|
||
if (node.summary || node.summary_pending) {
|
||
levelTitleEl.textContent = current.folder
|
||
? current.source + "/" + current.folder
|
||
: current.source;
|
||
levelSummaryEl.textContent = node.summary ||
|
||
"No description stored yet — the next sync will generate one. (You can write one yourself.)";
|
||
// Phase 98 (D4): the pending text is the muted marker style —
|
||
// and the block is REUSED across levels, so a stored level must
|
||
// clear a previous pending level's class (the same state
|
||
// closeEditor manages on the editor's close).
|
||
levelSummaryEl.className = node.summary ? "" : "kb-summary-pending";
|
||
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;
|
||
},
|
||
// Phase 98 (D4): the level's pending display (the D4 note — no
|
||
// tooltip on the level's <p>: D4's title is the row cell's).
|
||
pendingText:
|
||
"No description stored yet — the next sync will generate one. (You can write one yourself.)",
|
||
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();
|
||
}
|