**Phase 193 — final verification pass: all gates green.**
- **Verified (all 3 tasks already complete in `complete/193_sync_remaining_count/`):** `fmtSyncLabel`'s count part is `total > 0 && total - done > 0 ? \`${total - done} remaining\` : ""` (plain int, hidden at `N==0`, `total===0` → no part); docstrings updated; chunk/image/summaries parts + `counts_line` terminal + `app/` byte-untouched (`git diff --stat`: no `app/` files).
- **Completion criteria:** `N remaining` shape + marker pins ✅ · upload shares the builder (E2E upload arm) ✅ · chunk/image/summaries pins green ✅ · terminal `counts_line` green unedited + A4 title/`#sync-progress` pin ✅ · suite green, coverage 99% ✅ · lint/types clean ✅ · commit + `todo/→complete/` left to the harness (no git index/history commands run).
- `uv run pytest -q --cov=app --cov-report=term-missing` → **exit 0**, junit: **4792 tests, 0 failures/errors/skipped**, `TOTAL 7181 16 → 99%` (>90%)
- `uv run pytest tests/e2e/test_sync_remaining_count.py -v --no-cov` → **3 passed** (isolated, 53 s)
- Isolated `--no-cov` sweep of the 14 sync-lineage suites (label_mobile, upload_progress, chunk_progress, image_progress, progress_box, summary_visibility, archive_upload_sources, status_freshness, upload_no_scan, peak_memory_visible, session_reset, fast_path_deferred_content, git_freeze, smoke) → **14/14 exit 0**
- `uv run ruff check .` → All checks passed · `uv run pyright` → **0 errors**
- **Docs:** README.md reviewed in full (all 2408 lines) + AGENTS.md. Fixed 2 inconsistencies: the sync running-label clause claimed the upload label shows the count — now states it shares the ONE builder/shape but reports no counts since phase 90 (renders bare); phase-190's "the other ~72 rows" → "the other 83 rows of the panel's 86-row catalogue" (mechanically: 86 CATALOG rows, 84 overridable). `N remaining` already in the sync paragraph; task-01's `(n/m)` examples gone. AGENTS.md verified consistent (no clause names the running count part → unchanged per the phase's "do not add if absent"). Mechanically re-verified: all 86 `BOR_*` settings documented + defaults match, `.env.example` 88 vars covered, 0 broken README anchors, all cited file paths exist, 17 palette colors, migrations head `0030`, pages/shell routes, ports 8123/8901, E2E port allocations (new suite's 8211/8914 unique).
- **Notable:** task 02's documented deviation stands — `files_done` is the importer's 1-based *current* file, so the phase doc's `[1-4]` range is unreachable; count-bearing frames are `1 … N−1` (pinned). Pre-existing, untouched: `E2E_APP_PORT_TOOLBUDGET`/`SAVEDOC` both 8129 and `SYNCUP`/`SYCNCEL` both 8133 — harmless under the one-suite-at-a-time rule.
- **Next pending phase:** none — `193_sync_remaining_count` is the last entry in `.agents/phases/todo/`.
2539 lines
124 KiB
JavaScript
2539 lines
124 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 `loadTree()` (sources → folders → files, each folder carrying
|
||
* its STORED description — the rows the agent reads), and the view
|
||
* drills level by level with no URL change (the navigation is a
|
||
* re-render). PHASE 167 SUPERSEDED the single-payload model here
|
||
* (TODO L5 — the 2 MB first load, LOCKED A3): ONE
|
||
* `GET /api/docs/tree?depth=1` plus ONE node fetch per not-yet-loaded
|
||
* level — see the Phase 167 (task 04) section; the drill UX itself
|
||
* (breadcrumb, scroll panel, editing) is unchanged.
|
||
*
|
||
* • 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 show the KB-wide numbers (document count, chunks
|
||
* sum, max indexed_at) — identical to the former flat walk. Since
|
||
* phase 167 they read the server's `totals` (a depth-limited tree
|
||
* cannot be summed client-side); the whole-tree walk stays only as
|
||
* the defensive no-`totals` fallback.
|
||
* • 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 count part's TEXT is `N remaining` — the distance
|
||
* to the end — since phase 193). 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-progress 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).
|
||
*
|
||
* Phase 99 (task 01, D1) — the ONE-LINE Description clamp: the
|
||
* owner's long stored descriptions no longer stretch the catalog
|
||
* rows. A row's Description cell is now ONE flex wrapper
|
||
* (`.kb-desc-cell` — inside the <td>, which gains no new class) holding
|
||
* the text span (`.kb-desc-text` — flex: 1 1 auto + min-width: 0 (the
|
||
* flex item may shrink), overflow: hidden + text-overflow: ellipsis +
|
||
* white-space: nowrap: the first line that fits, ellipsized — the row
|
||
* height is ONE line, independent of the description's length) and the
|
||
* ALWAYS-present Edit button (`.kb-summary-edit`, flex: 0 0 auto — it
|
||
* never wraps to a second line; its spacing comes from the wrapper's
|
||
* gap, its old margin-left is gone). The clamp is VISUAL only: the
|
||
* full text stays in the DOM (the accessible name is unchanged) and
|
||
* rides the span's title (the hover escape hatch, the house pattern) —
|
||
* set for real description text whenever non-empty; the phase-98
|
||
* marker keeps its OWN D4 title (never overridden), the empty cell
|
||
* carries none. The editor's open/close swaps happen INSIDE the
|
||
* wrapper (wireDescriptionEdit's row container IS the wrapper — the
|
||
* <td> always keeps it; the level block's container stays
|
||
* .kb-level-body), and closeEditor toggles ONLY the
|
||
* kb-summary-pending class (the base classes survive) while re-deriving
|
||
* the clamped span's title from the stored text (a stale pre-edit
|
||
* title cannot survive a save). The level block (`.kb-level p`) keeps
|
||
* the FULL unclamped description — the escape hatch at the top when
|
||
* you click in.
|
||
*
|
||
* Phase 99 (task 02, D2) — the browser back/forward walks the
|
||
* breadcrumb: the drill state IS the history state. Every user-
|
||
* initiated drill (source-row click, folder-link click, breadcrumb-
|
||
* segment click — all through goTo) goes through applyTarget(target,
|
||
* true): when the target DIFFERS from the current level, it FIRST
|
||
* pushes a STATE-ONLY entry — history.pushState({ view: "rag", kb:
|
||
* target }, "") — then sets current + re-renders. The push is the
|
||
* STATE only: the second arg is "" and there is no third — the URL
|
||
* STAYS PUT (no new route, the phase-76 deep-link surface is
|
||
* untouched; the shell's pathname is the only route contract). A
|
||
* re-click of the current level pushes NOTHING (no duplicate history
|
||
* entries). Back/forward: the window popstate (armed ONCE at mount,
|
||
* admin branch only — the anonymous gate installs no listeners) ADOPTS
|
||
* the popped entry's state.kb via the no-push variant (applyTarget(kb,
|
||
* false) — the browser owns its own entries, so adopt/reset never
|
||
* push); an entry WITHOUT a kb (the boot entry, the router's view
|
||
* entries, any foreign state) RESETS the drill to the TOP level
|
||
* (applyTarget({ source: null, folder: null }, false)). Back at the
|
||
* top level pops the router's view entry — the view leaves, exactly
|
||
* as today (the router's pathname-based switch, UNCHANGED). The
|
||
* re-show (bor:view-refresh) aligns the same way BEFORE the re-fetch:
|
||
* a kb-carrying top entry (the active-link re-click pushed NOTHING —
|
||
* the drilled entry is still on top) KEEPS the drill; a kb-less entry
|
||
* (a fresh nav visit) starts at the top level. Boot pushes NOTHING
|
||
* (the browser's own entry is the base — the phase-76 boot contract),
|
||
* and the never-stale resetVanishedLocation is UNCHANGED (it resets
|
||
* current WITHOUT pushing when a re-fetch finds the level gone — it
|
||
* follows whatever the history state says; no new entry). The router
|
||
* module is UNTOUCHED: its popstate reads the pathname only, and a
|
||
* state-only push never changes it (so rag-internal entries never
|
||
* trigger a view switch; for a foreign entry the router has already
|
||
* switched views, and this listener's render of the now-hidden view
|
||
* is harmless).
|
||
*
|
||
* Phase 106 (task 08, D8) — the date COLUMNS (display only — the
|
||
* admin date EDITOR is task 09):
|
||
*
|
||
* • the file table gains the Created column BEFORE Indexed (the
|
||
* owner's verbatim position, D8): makeRow builds the date cells
|
||
* explicitly (the plain-td loop can't carry per-cell titles) —
|
||
* textContent = the locale date (fmtDate, the Indexed idiom), and
|
||
* the Created cell's title = the FULL ISO value (hover precision —
|
||
* the path-cell idiom; the E2E asserts on the locale-stable
|
||
* title, not on the toLocaleString output). The row object fed
|
||
* from the tree's file nodes carries created_at (task 05's tree
|
||
* shape — the flat GET /api/docs shape has the field too).
|
||
* • the folder/source table gains the Updated column BETWEEN
|
||
* Documents and Description (the owner's verbatim position): the
|
||
* subtree's MAX document created_at — the tree API DERIVES it
|
||
* (D9, never stored); makeSourceRow / makeFolderRow render
|
||
* fmtDate(updated_at) with the ISO value on the cell's title and
|
||
* the "–" null idiom (the statLast shape) for a 0-document
|
||
* source.
|
||
* • the stat cards are UNTOUCHED — they keep their indexed_at
|
||
* "last indexed" semantics (the owner asked for the column, not
|
||
* the cards).
|
||
*
|
||
* Phase 122 (task 04) — the image-doc thumbnail: a file node of the
|
||
* tree carries the image affordance (is_image / image_url / summary —
|
||
* OMITTED on text nodes, the wire-additive rule) when it is an image
|
||
* document. makeRow's Path cell then renders a FIXED 48px thumbnail
|
||
* box (object-fit: cover, loading="lazy", alt = the summary — the
|
||
* vision description; a NULL summary falls back to the title) BEFORE
|
||
* the path link (the .kb-doc-path flex wrapper — the link keeps its
|
||
* ellipsis). Progressive enhancement: a failed fetch (or the lazy
|
||
* first paint) swaps in the document glyph INSIDE the same fixed box
|
||
* (no layout shift beyond the box, no broken-image placeholder). Text
|
||
* rows never get a box (the pre-phase bare-link cell, byte-identical).
|
||
* The glyph is static SVG (aria-hidden — the alt text is the
|
||
* accessible content); the box + img are properties only, never
|
||
* innerHTML with document-derived data (the house rule).
|
||
*
|
||
* Phase 128 (task 01) — the sync label becomes PARTS: the running
|
||
* label's string is split into a shrinks-to-fit MAIN part (prefix +
|
||
* the file/folder path — the part CSS may ellipsize) and a pinned
|
||
* COUNT part ("(n/m)" — the position, NEVER clipped — the text is
|
||
* `N remaining` since phase 193), so on a phone
|
||
* the file path ellipsizes to fit while the count stays on-screen
|
||
* (the phase-64 bug: with max-width lifted, the inline-block sized to
|
||
* its full content and the count ran off-screen). #sync-label now
|
||
* ships TWO child spans — .sync-label-main (the initial idle text
|
||
* lives there so the pre-JS label renders) + .sync-label-count
|
||
* (hidden + empty by default) — and the label builders no longer
|
||
* return one string: fmtSyncLabel / fmtSyncPhaseLabel return the
|
||
* PARTS ({ main, count } — the same decision tree, the same byte-
|
||
* identical copy), and ONE writer — setSyncLabel(mainText,
|
||
* countText) — lands them: mainText → the main part's textContent,
|
||
* countText → the count part's textContent with the hidden toggle
|
||
* (absent count → hidden + empty), then the UNTRUNCATED full label
|
||
* — mainText + (countText ? " " + countText : "") — onto the button
|
||
* title + #sync-progress (A4 — the screen reader hears the whole
|
||
* thing; the visible span carries main + count, the space between
|
||
* them is the CSS gap). Every other #sync-label writer — the idle /
|
||
* settle labels ("Sync sources", "Synced <stamp>" — the date-
|
||
* qualified stamp from fmtSyncTime, phase 154: `HH:MM` on the same
|
||
* local day, the dated form otherwise), the 202/409 click,
|
||
* the initSyncButton re-attach — goes through setSyncLabel(main)
|
||
* (count hidden). The full-string shapes are byte-identical to the
|
||
* pre-task labels ("Syncing…", "Syncing… <file>",
|
||
* "Syncing… <file> (n/m)" — `N remaining` since phase 193, "Importing",
|
||
* "Writing KB overview…",
|
||
* "Summarizing folders… <folder> (n/m)"); textContent only — no HTML
|
||
* strings (the house rule).
|
||
*
|
||
* Phase 134 (task 02) — the Cancel control + the cancelled terminal:
|
||
* the owner can stop a running sync. #sync-cancel-btn (the static
|
||
* markup next to #sync-btn — ship-hidden, the same convention) is
|
||
* visible + enabled ONLY while a SYNC run is in flight:
|
||
* enterSyncRunningState shows it for kind "sync" (the live click, the
|
||
* load-time re-attach, and every running poll tick) and hides it for
|
||
* kind "upload" (the upload job is NOT cancellable in this phase —
|
||
* its run keeps the control hidden); settleSyncButton hides it on
|
||
* EVERY settle (every terminal + both idles — one choke point). A
|
||
* click runs requestCancel() under the same never-stale contract as
|
||
* the main button: the control disables IMMEDIATELY (one request at
|
||
* a time — no double fire) BEFORE the POST /api/sync/cancel —
|
||
* 403 → the whoami-backstop hide-everything branch (the same depth as
|
||
* every other 403 in this module), 409 → SILENT (the run ended
|
||
* between the tick and the click — the poll settles on the next tick,
|
||
* which hides the control; no error copy), 202 → the control stays
|
||
* disabled until the terminal (the poll's new cancelled branch settles
|
||
* everything — the status object, not the request, is authoritative),
|
||
* any other non-2xx / network failure → the phase-55 neutral retry
|
||
* copy ("Couldn't cancel the sync — try again.") + re-enable (the
|
||
* run is still going — the owner can retry). The poll tree gains its
|
||
* 5th branch (the 7-branch tree becomes 8): state "cancelled" — after
|
||
* the failed branch, before the upload branches — stops the poll and
|
||
* runs applySyncCancelled: the NEUTRAL result (A1 — cancel is intent,
|
||
* not failure): settle the main button retry-ready (which also hides
|
||
* the Cancel control), "Sync cancelled" on the aria-live result line,
|
||
* the error banner hidden, the status frame emitted — no modal, no
|
||
* error styling. The initSyncButton re-attach renders the same result
|
||
* for a reload during/right after a cancel (a page reload shows
|
||
* "Sync cancelled", never a stuck running state). The bor:sync-status
|
||
* event shape is unchanged — the cancelled status object flows through
|
||
* emitSyncStatus exactly like every other state.
|
||
*
|
||
* Phase 149 (task 01) — the sync detail box + the icon-only running
|
||
* button: the owner's report was that the full live label (a long
|
||
* source/relative/path + the counts + the chunk part + the phase-152
|
||
* image part) is clipped in
|
||
* the pill even on desktop — the mobile solution (stuffing the parts
|
||
* in the button) had become the permanent surface. The fix: while a
|
||
* sync/upload run is in flight, the button shows ONLY the spinning
|
||
* icon (the `is-running` class — task 02's CSS hides the label parts;
|
||
* the button IS the loading spinner, exactly the owner's ask), and a
|
||
* NEW bordered text box under the button row (#sync-progress) shows
|
||
* the SAME full untruncated live label — wrapping, never clipped,
|
||
* breathing room. Phase 150 (task 02) folded the announcer into the
|
||
* box — the previous live-region span was retired and the box is now
|
||
* the SOLE sync text surface and the sole aria-live region (one live
|
||
* region, no double announce). The wiring rides the SINGLE writer:
|
||
* setSyncLabel gains the `running` flag (true on the running entry —
|
||
* the poll ticks, the 202/409 click, both re-attach entries; the
|
||
* settle default false) and writes the full label into the box +
|
||
* reveals/hides it, and owns the button's accessible name (full live
|
||
* label while running, the ship value "Sync sources" restored on
|
||
* every settle). The `is-running`
|
||
* class travels with the spin: added in enterSyncRunningState, removed
|
||
* in settleSyncButton (every terminal + both idles pass through it).
|
||
* The settle rendering ("Synced <stamp>" — the date-qualified stamp,
|
||
* phase 154 — + the counts line), the Cancel
|
||
* control, the 409 double-trigger, the error banner/modal, and the
|
||
* sync status API are all UNCHANGED.
|
||
*
|
||
* Phase 150 (task 02) — one box, one writer: the phase-149
|
||
* live-region span that sat beside the box is retired —
|
||
* #sync-progress is the SOLE sync text surface AND the sole
|
||
* aria-live region (role="status" aria-live="polite", task 01).
|
||
* setSyncProgress(text) is the ONLY function that writes the box:
|
||
* the text lands on the inner #sync-progress-text span (textContent
|
||
* only — the house rule, never HTML) and the box's visibility is
|
||
* owned by this writer alone (hidden IFF the text is empty — the
|
||
* global [hidden] rule does the display). Two content modes, decided
|
||
* at the callsites (the phase-149 "the callsites are the only
|
||
* decision sites" rule):
|
||
* • running mode — setSyncLabel(main, count, chunk, running, image)
|
||
* writes
|
||
* the SAME untruncated full label through setSyncProgress, ONLY
|
||
* while running (the idle / settle button labels — "Sync sources",
|
||
* "Synced <stamp>" (the date-qualified stamp, phase 154) — must
|
||
* not fill the panel; they stay on the
|
||
* button, A2);
|
||
* • result mode — the settle paths write the result line through
|
||
* setSyncProgress: the counts line (fmtSyncResult — "N added ·
|
||
* …"), "Sync cancelled", the cancel-failure copy ("Couldn't
|
||
* cancel the sync — try again."); "" hides the box (the true
|
||
* idle, the two upload settles — the upload's result line lives
|
||
* on the Sources page, A3 — and the sync failure, whose banner +
|
||
* modal carry the error).
|
||
* requestCancel's pre-fetch clear is a NO-OP on the box (the run is
|
||
* still in flight — blanking would hide the running surface for up
|
||
* to one 2 s poll; the poll's `cancelled` branch settles it, and the
|
||
* control's immediate disable keeps the never-stale contract). The
|
||
* running label, the button title, and the button aria-label are
|
||
* byte-identical to pre-phase; no copy changed.
|
||
*
|
||
* Phase 167 (task 04, D4/A3 — TODO L5) — the LAZY DRILL: the 2 MB
|
||
* first payload is gone. The catalog load is ONE
|
||
* `fetch("/api/docs/tree?depth=1")` (the sources plus one level plus
|
||
* the server's `totals`), and a folder the payload only NAMES
|
||
* (`node.truncated === true`, not yet in `levelCache`) fetches
|
||
* `GET /api/docs/tree/node?source=…&path=…` the FIRST time it opens —
|
||
* the merged children ride the node (Object.assign, so
|
||
* `currentLevelNode()`/`renderLevel()` keep their pre-phase semantics
|
||
* for every loaded level), the level is cached (key
|
||
* `source + "/" + folder`), and re-opening it is free (zero network).
|
||
* A NEW tree load clears the cache — the sync/upload/re-show refresh
|
||
* call sites keep working unchanged — and rehydrates the user's OWN
|
||
* level when the fresh payload truncates it (a refresh never paints a
|
||
* half-empty level). §7.4 never-stale, in full: while the node fetch
|
||
* runs the level the user is LEAVING stays rendered, the clicked link
|
||
* carries aria-busy + aria-disabled (drillPending — a second click
|
||
* cannot start a parallel fetch; adopted popstate navigations
|
||
* rehydrate their level the same way, via ensureCurrentLevel) and
|
||
* ONE role="status"
|
||
* "Loading <folder>…" row sits in the folders panel; a failure shows
|
||
* a role="alert" line with a real Retry <button> and keeps the
|
||
* current level rendered; the phase-79 loadSeq token EXTENDS to the
|
||
* drill (a response superseded by a newer load — or by a navigation —
|
||
* never paints). The node route's 404 IS the vanished-folder signal:
|
||
* the honest reset to the top level, nothing cached. The existence
|
||
* rules stop walking a full tree — folderKnownIn() only claims what
|
||
* the HELD levels prove — and treeStats() prefers kbTree.totals (the
|
||
* walk is the pre-phase-167 fallback only).
|
||
*
|
||
* Phase 187 (task 03, D4/A4) — the sync result line carries the run's
|
||
* PEAK MEMORY. The two additive `rss_mb` / `rss_peak_mb` keys on
|
||
* GET /api/sync/status feed ONE clause builder (fmtSyncPeakClause),
|
||
* which APPENDS ` · peak 512 MB` to the result text the settle already
|
||
* wrote (the fmtSyncResult counts line, "Sync cancelled") — no new
|
||
* control, no new state string, no new live region, and the failed
|
||
* terminal's empty box is untouched (the banner + modal keep the error
|
||
* copy byte-identical, phase 150). Null — idle, a run that predates the
|
||
* phase, a platform that cannot know, and the upload job (whose status
|
||
* has no memory keys at all) — renders NOTHING, never "peak ? MB". The
|
||
* clause rides the single box writer, so the sole aria-live region
|
||
* announces today's wording plus the clause, and the number is the
|
||
* PROCESS high-water mark app/core/memory.py reports (never the host's
|
||
* cgroup figure — that one counts page cache).
|
||
*/
|
||
|
||
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 cell's .kb-desc-cell wrapper — Phase 99
|
||
* (task 01) — 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.
|
||
* Phase 99 (task 01): the class line toggles ONLY kb-summary-pending
|
||
* (the base classes survive — the row's span keeps .kb-desc-text, the
|
||
* clamp; the level's <p> keeps none), and the CLAMPED row span's
|
||
* title is re-derived from the stored text (the D1 hover escape hatch
|
||
* — a stale pre-edit title cannot survive a save; the level's <p> is
|
||
* unclamped — it shows the full text and keeps its no-title
|
||
* behavior). */
|
||
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.classList.toggle("kb-summary-pending", pending);
|
||
if (pending) {
|
||
if (pendingTitle) textEl.title = pendingTitle;
|
||
} else if (stored && textEl.classList.contains("kb-desc-text")) {
|
||
textEl.title = stored; // D1: full text on hover — the clamp is visual only
|
||
} 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).
|
||
* Phase 99 (task 01, D1): the cell is ONE flex row — the <td> holds a
|
||
* single `div.kb-desc-cell` wrapper with the text span (`.kb-desc-text`
|
||
* — flexes + ellipsizes, so the row height is ONE line, independent of
|
||
* the description's length) and the Edit button (`.kb-summary-edit` —
|
||
* flex: 0 0 auto, it never wraps). The clamp is VISUAL only: the full
|
||
* text stays in the DOM and rides the span's title (the hover escape
|
||
* hatch, set for real description text whenever non-empty — the marker
|
||
* keeps its OWN D4 title, the empty cell carries none). The editor's
|
||
* open/close swaps happen INSIDE the wrapper (the container is the
|
||
* wrapper — the <td> always keeps it).
|
||
* Phase 156 (task 02, A3): the Edit button's visible text is the
|
||
* .cell-clip span — a squeezed cell shows "…" instead of a hard cut;
|
||
* the aria-label names the row (the source / the source/folder path
|
||
* — the phase-97 idiom), so the full text stays reachable. */
|
||
function makeDescCell(node, source, folder, label) {
|
||
const td = document.createElement("td");
|
||
/* Phase 99 (task 01, D1): the ONE flex wrapper — the text flexes +
|
||
* ellipsizes, the button stays fixed (see the docstring above). */
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "kb-desc-cell";
|
||
const text = document.createElement("span");
|
||
text.className = "kb-desc-text";
|
||
if (node && node.summary) {
|
||
text.textContent = node.summary; // stored description — text node, never innerHTML
|
||
text.title = node.summary; // full text on hover — the clamp is visual only (D1)
|
||
} else if (node && node.summary_pending) {
|
||
// Phase 98 (D4): the summary is due but not stored yet — the marker
|
||
// keeps its OWN title (the hover rule never overrides it).
|
||
text.classList.add("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, no title)
|
||
}
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "kb-summary-edit";
|
||
/* Phase 156 (task 02, A3): the visible text is the .cell-clip
|
||
span — a squeezed Description cell shows "…" instead of a hard
|
||
cut; the aria-label below names the row (the source / the
|
||
source/folder path — the phase-97 idiom), so the full text
|
||
stays reachable. */
|
||
const clip = document.createElement("span");
|
||
clip.className = "cell-clip";
|
||
clip.textContent = "Edit"; // never innerHTML (XSS contract)
|
||
btn.append(clip);
|
||
btn.setAttribute("aria-label", `Edit description: ${label}`);
|
||
wrap.append(text, btn);
|
||
td.append(wrap);
|
||
wireDescriptionEdit({
|
||
editBtn: btn,
|
||
textEl: text,
|
||
container: wrap, // Phase 99 (task 01): the swaps fill the WRAPPER (the <td> keeps it)
|
||
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 remaining" — 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. sync cancelled → the phase-134 neutral terminal (task 02 —
|
||
* A1: cancel is intent, not failure): settle
|
||
* the main button retry-ready + "Sync
|
||
* cancelled" on #sync-progress, no banner / no
|
||
* modal (applySyncCancelled); the settle also
|
||
* hides the Cancel control;
|
||
* 6. 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-progress (A3);
|
||
* 7. upload failed → settle "Sync sources" — the failure is the
|
||
* Sources page's error banner, never this page's (A3);
|
||
* 8. both idle → retry-ready idle.
|
||
* Phase 134 (task 02) — the Cancel control (#sync-cancel-btn, the
|
||
* static markup next to #sync-btn — ship-hidden, the same
|
||
* convention): visible + enabled ONLY while a SYNC run is in
|
||
* flight. The show/hide contract has two choke points:
|
||
* enterSyncRunningState shows it for kind "sync" (the live 202/409
|
||
* click, the load-time re-attach, and every running tick of branch
|
||
* 1) and hides it for kind "upload" (the upload job is NOT
|
||
* cancellable in this phase — its run keeps the control hidden);
|
||
* settleSyncButton hides it on EVERY settle (branches 3–8 all pass
|
||
* through it). The click runs requestCancel(): the control disables
|
||
* IMMEDIATELY (one request at a time — never stale) before
|
||
* POST /api/sync/cancel; 403 → the whoami-backstop branch (stop
|
||
* polling, hide the main button, settle idle — the same depth as
|
||
* every other 403 in this module); 409 → SILENT (the run ended
|
||
* between the tick and the click — the poll settles on the next
|
||
* tick and its settle hides the control; no error copy); 202 →
|
||
* nothing (the control stays disabled — the poll's branch 5 settles
|
||
* everything; the run may already be terminal by then — the status
|
||
* object, not the request, is authoritative); any other non-2xx /
|
||
* network failure → the phase-55 neutral retry copy on
|
||
* #sync-progress (the box) + re-enable (the run is still going —
|
||
* the owner can retry).
|
||
* 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-progress (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 —
|
||
* Phase 128 (task 01): the PARTS — .sync-label-main, the
|
||
* shrinks-to-fit prefix + file/folder, + .sync-label-count, the
|
||
* pinned count — the phase-193 "N remaining" (hidden when absent);
|
||
* phase 129 (task
|
||
* 04): + .sync-label-chunk, the pinned "· chunk n/m" embed-batch
|
||
* position of the in-flight file (hidden in every state but the
|
||
* import phase after the file's first batch); phase 152 (task 03):
|
||
* the in-flight PDF's "· image n/m" vision-image position gets NO
|
||
* span of its own — the running button is the spinner (its parts
|
||
* are hidden, phase 149), so the position rides only the composed
|
||
* full string (the box / title / running aria-label)),
|
||
* #sync-cancel-btn
|
||
* (the Cancel control — phase 134 task 02, ship-hidden, revealed
|
||
* only while a sync run is in flight), #sync-icon (the
|
||
* spinner icon), #sync-progress (Phase 149 task 01 + Phase 150:
|
||
* the full untruncated live label's box under the button row —
|
||
* the SOLE sync text surface AND the sole aria-live region
|
||
* (role="status" aria-live="polite"); visible while a run is in
|
||
* flight (the live label) and after a settle (the result line —
|
||
* the counts line, "Sync cancelled", the cancel-failure copy),
|
||
* hidden only when its text is empty; the .sync-progress-text
|
||
* span carries the label),
|
||
* #sync-error-banner / #sync-error-text (error banner).
|
||
*/
|
||
const syncBtn = root.querySelector("#sync-btn");
|
||
// Phase 134 (task 02): the Cancel control — visible + enabled ONLY
|
||
// while a SYNC run is in flight (the section header carries the
|
||
// show/hide contract); the ship-hidden attribute stays until the
|
||
// first running sync state.
|
||
const syncCancelBtn = root.querySelector("#sync-cancel-btn");
|
||
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
||
const syncLabelMain = syncLabel ? syncLabel.querySelector(".sync-label-main") : null;
|
||
const syncLabelCount = syncLabel ? syncLabel.querySelector(".sync-label-count") : null;
|
||
const syncLabelChunk = syncLabel ? syncLabel.querySelector(".sync-label-chunk") : null;
|
||
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||
// Phase 149 (task 01) + Phase 150 (task 02): the sync detail box —
|
||
// the full UNTRUNCATED live label in a bordered panel under the
|
||
// button row, the SOLE sync text surface AND the sole aria-live
|
||
// region for the sync area (the previous live-region span was
|
||
// retired in phase 150 — one live region, no double announce).
|
||
// Its text + visibility ride the single setSyncProgress writer
|
||
// (the running label while a run is in flight; the settle result
|
||
// line after it — the box hides only when its text is empty).
|
||
const syncProgress = root.querySelector("#sync-progress");
|
||
const syncProgressText = syncProgress
|
||
? syncProgress.querySelector(".sync-progress-text")
|
||
: null;
|
||
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;
|
||
}
|
||
}
|
||
|
||
/* Phase 154 (task 01): the contract widens to the DATE-QUALIFIER
|
||
* (the phase design's A1 rule — owner-locked; the stamp is
|
||
* computed from the ISO's LOCAL calendar date, the page's clock —
|
||
* the table `Updated` columns already render local dates):
|
||
* • same local calendar day as now → `HH:MM` (byte-identical to
|
||
* the pre-phase output — a same-day badge is unchanged);
|
||
* • same local year, different day → `M/D, h:MM AM/PM`
|
||
* (e.g. `10/2, 3:14 PM`);
|
||
* • different local year → `M/D/YYYY, h:MM AM/PM`
|
||
* (e.g. `1/3/2025, 9:12 PM`).
|
||
* `h:MM` is the 12-hour form WITHOUT a leading zero on the hour
|
||
* (the `12:05 AM` edge), month/day without leading zero (the table
|
||
* dates' `M/D/YYYY` / `5:34:06 PM` family, the seconds dropped).
|
||
* The parse guard is UNCHANGED — an unparseable/missing ISO still
|
||
* yields "" (the bare "Synced" settle — e.g. the app-restart case,
|
||
* A12). */
|
||
function fmtSyncTime(iso) {
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
const now = new Date();
|
||
const pad = (n) => String(n).padStart(2, "0");
|
||
if (
|
||
d.getFullYear() === now.getFullYear() &&
|
||
d.getMonth() === now.getMonth() &&
|
||
d.getDate() === now.getDate()
|
||
)
|
||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
const h12 = d.getHours() % 12 || 12;
|
||
const ampm = d.getHours() < 12 ? "AM" : "PM";
|
||
const date =
|
||
d.getFullYear() === now.getFullYear()
|
||
? `${d.getMonth() + 1}/${d.getDate()}`
|
||
: `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
|
||
return `${date}, ${h12}:${pad(d.getMinutes())} ${ampm}`;
|
||
}
|
||
|
||
/* Phase 128 (task 01) + Phase 129 (task 04) + Phase 152 (task 03):
|
||
* the label PARTS writer — the SINGLE point that writes #sync-label.
|
||
* mainText lands in the MAIN part (.sync-label-main — the
|
||
* shrinks-to-fit prefix + file/folder, the part CSS ellipsizes);
|
||
* countText lands in the COUNT part (.sync-label-count — the pinned
|
||
* phase-193 "N remaining", NEVER clipped); chunkText (phase 129) lands in
|
||
* the CHUNK part (.sync-label-chunk — the pinned "· chunk n/m"
|
||
* embed-batch position of the in-flight file); imageText (phase
|
||
* 152) is the in-flight PDF's "· image n/m" vision-image position —
|
||
* it gets NO span of its own (the running button is the spinner —
|
||
* its parts are hidden, phase 149), it rides ONLY the composed full
|
||
* string. The COUNT and CHUNK parts share one contract: present →
|
||
* its textContent + hidden=false, absent → emptied + hidden=true
|
||
* (each span is hidden whenever there is nothing of its kind to
|
||
* show). Then the UNTRUNCATED full label — mainText + (countText ?
|
||
* " " + countText : "") + (chunkText ? " " + chunkText : "") +
|
||
* (imageText ? " " + imageText : "") — rides the button title (A4 —
|
||
* the screen reader hears the whole "Syncing… <file> (2/3) · chunk
|
||
* 14/38 · image 3/15" there, and in the #sync-progress box — the
|
||
* sole aria-live region, phase 150 — while a run is in flight); the
|
||
* visible spans carry main + count + chunk, the spaces between them
|
||
* are the CSS gap. textContent only — the house rule, no HTML
|
||
* strings. Every label state goes through here (the running
|
||
* builders, the idle / settle labels, the 202/409 click, the
|
||
* re-attach).
|
||
* Phase 149 (task 01): the fourth parameter — `running` (the
|
||
* in-flight flag; the callsites are the ONLY decision sites: the
|
||
* running entry passes true, the settle passes the default false).
|
||
* Phase 152 (task 03): the fifth parameter — `imageText` (the "·
|
||
* image n/m" part; the chunk and image parts are distinct and both
|
||
* may show, and each disappears independently — the bare builders
|
||
* and the non-running calls pass nothing, so both default "").
|
||
* Phase 150 (task 02): while running, the SAME untruncated `full`
|
||
* string goes into the box through the single box writer
|
||
* (setSyncProgress — textContent only), so the box can never
|
||
* disagree with the button title / aria; a non-running call leaves
|
||
* the box alone — the idle / settle button labels must not fill
|
||
* the panel, and the settle paths own the box's result line. The
|
||
* button's accessible name rides here too: while running the
|
||
* icon-only button (task 02 hides the label parts) reads the FULL
|
||
* live label (the title already carries it for hover); every
|
||
* non-running call restores the ship value "Sync sources" (the
|
||
* static HTML attribute is the idle truth — the JS only
|
||
* overwrites/restores it, never removes it).
|
||
* Phase 154 (task 01): the settle badge among these label parts is
|
||
* date-qualified — `Synced <stamp>`, where `<stamp>` is produced
|
||
* by fmtSyncTime (its contract widened to the phase-154 A1 rule —
|
||
* same-day stays `HH:MM`, non-same-day carries the date; the rule
|
||
* is cited there, not restated). The parts themselves are
|
||
* UNCHANGED — the longer stamp is just more text in the same part
|
||
* (the label part ellipsizes as before). */
|
||
function setSyncLabel(mainText, countText = "", chunkText = "", running = false, imageText = "") {
|
||
if (syncLabelMain) syncLabelMain.textContent = mainText;
|
||
if (syncLabelCount) {
|
||
if (countText) {
|
||
syncLabelCount.textContent = countText;
|
||
syncLabelCount.hidden = false;
|
||
} else {
|
||
syncLabelCount.textContent = "";
|
||
syncLabelCount.hidden = true;
|
||
}
|
||
}
|
||
if (syncLabelChunk) {
|
||
if (chunkText) {
|
||
syncLabelChunk.textContent = chunkText;
|
||
syncLabelChunk.hidden = false;
|
||
} else {
|
||
syncLabelChunk.textContent = "";
|
||
syncLabelChunk.hidden = true;
|
||
}
|
||
}
|
||
// Phase 152 (task 03): the image part is the LAST piece of the
|
||
// composed full string (after the chunk part) — the box, the
|
||
// button title, and the running aria-label all carry it; the
|
||
// button's label spans stay as-is (no new span — see the
|
||
// docstring).
|
||
const full =
|
||
mainText +
|
||
(countText ? ` ${countText}` : "") +
|
||
(chunkText ? ` ${chunkText}` : "") +
|
||
(imageText ? ` ${imageText}` : "");
|
||
if (syncBtn) syncBtn.title = full; // A4: the untruncated label rides the title, always
|
||
// Phase 150 (task 02): the box — the sole sync text surface and
|
||
// the sole aria-live region — receives the running label through
|
||
// the single box writer, and ONLY while a run is in flight (a
|
||
// non-running call leaves the box alone: the idle / settle button
|
||
// labels must not fill the panel).
|
||
if (running) setSyncProgress(full);
|
||
// The running button is the spinner (task 02 hides the label
|
||
// parts) — its accessible name is the full live label; every
|
||
// settle restores the ship value (see the function docstring).
|
||
if (syncBtn) syncBtn.setAttribute("aria-label", running ? full : "Sync sources");
|
||
}
|
||
|
||
/* Phase 150 (task 02): the SINGLE writer for the #sync-progress
|
||
* box — the ONLY function that touches its text or visibility.
|
||
* The text lands on the inner #sync-progress-text span (textContent
|
||
* only — the house rule, never HTML) and the box's visibility is
|
||
* owned by this writer alone: hidden IFF the text is empty (the
|
||
* global [hidden] rule does the display). The content mode is
|
||
* decided at the callsites (the phase-149 "the callsites are the
|
||
* only decision sites" rule): the running label (setSyncLabel
|
||
* passes `full` while a run is in flight) and the settle result
|
||
* line (the counts line, "Sync cancelled", the cancel-failure
|
||
* copy; "" hides the box — the true idle, the upload settles, the
|
||
* sync failure). The box is the SOLE sync text surface and the
|
||
* sole aria-live region for the sync area — one live region, no
|
||
* double announce. */
|
||
function setSyncProgress(text) {
|
||
if (!syncProgressText || !syncProgress) return;
|
||
syncProgressText.textContent = text;
|
||
syncProgress.hidden = text === "";
|
||
}
|
||
|
||
/* Phase 64 (task 04) + Phase 128 (task 01, the PARTS rework): 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) — lands in the MAIN part 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 — and when an upload
|
||
* status DOES carry counts the upload job shares this builder and
|
||
* the shape (phase 193, A2: the upload's files_total is the
|
||
* pre-walk count, the phase-98 contract — the same subtraction
|
||
* semantics). The count — the PINNED count part, never clipped
|
||
* (Phase 128) — is the phase-193 COUNT-DOWN part (the owner's
|
||
* "how many files are left, not how many files out of the
|
||
* total"): `N remaining` with N = total - done, shown while
|
||
* N >= 1 and HIDDEN at the final file (N == 0 — the main part
|
||
* names it; a "0 remaining" next to the last file's name is the
|
||
* stale-looking edge); plain integer, no thousands separator. The
|
||
* total > 0 guard stays (the phase-64 pre-walk contract — no count
|
||
* part before the walk knows the total). The builders return the
|
||
* PARTS
|
||
* ({ main, count }); setSyncLabel writes them (the untruncated full
|
||
* string — main + " " + count — rides the button title +
|
||
* #sync-progress, A4). 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). Phase 129 (task
|
||
* 04): this builder still returns NO chunk part — the pinned
|
||
* "· chunk n/m" part is added by fmtSyncPhaseLabel's import
|
||
* fall-through (the only site that sees the status's chunk
|
||
* fields), and phase 152 (task 03) likewise adds the "· image
|
||
* n/m" part there (the only site that sees the status's image
|
||
* fields), so the upload label and the 202/409 click stay bare. */
|
||
function fmtSyncLabel(kind, currentFile, done, total) {
|
||
const prefix = kind === "upload" ? "Importing" : "Syncing…";
|
||
const main = currentFile ? `${prefix} ${currentFile}` : prefix;
|
||
const count = total > 0 && total - done > 0 ? `${total - done} remaining` : "";
|
||
return { main, count };
|
||
}
|
||
|
||
/* Phase 98 (task 02, D2) + Phase 128 (task 01, the PARTS rework)
|
||
* + Phase 152 (task 03): 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…" (no count part); "summaries" → main
|
||
* "Summarizing folders… <source/folder>" + count "(n/m)" — the
|
||
* folder part is omitted while current_summary is null (the phase's
|
||
* first poll) and the COUNT IS ALWAYS EMITTED in this phase (the
|
||
* hook always carries a total) and the CHUNK and IMAGE parts stay
|
||
* empty (the summaries phase's own (n/m) is its position — the
|
||
* status's chunk + image fields are cleared at this transition);
|
||
* anything else (the null prelude, "import") → the phase-64
|
||
* fmtSyncLabel parts (same builder, same fields, same order —
|
||
* since phase 193 its count part reads `N remaining`, N =
|
||
* total - done, hidden at the final file; the summaries (n/m)
|
||
* folder position and the chunk/image parts below stay as they
|
||
* are) + the phase-129 CHUNK part: "· chunk n/m" ONLY while
|
||
* chunks_total > 0 (a file's embed batch is in flight — n the
|
||
* 1-based first chunk of the batch, m the file's total content
|
||
* chunks, batch granularity), empty before the first batch (0/0) so
|
||
* the part hides + the phase-152 IMAGE part: "· image n/m" ONLY
|
||
* while images_total > 0 (a PDF's vision span is in flight — n the
|
||
* 1-based image about to be described, m the file's total images —
|
||
* ONE continuous counter across the PDF's three vision spans, the
|
||
* importer owns the continuity), empty before the first image
|
||
* (0/0) so the part hides; the two positions are distinct and both
|
||
* may show ("… · chunk 125/126 · image 3/15"), each disappearing
|
||
* independently. The UPLOAD job never passes a
|
||
* status (its endpoint has no phase — D2) and keeps the bare
|
||
* fmtSyncLabel parts. The untruncated full label rides the button
|
||
* title + #sync-progress like every running label (A4 —
|
||
* setSyncLabel). */
|
||
function fmtSyncPhaseLabel(status) {
|
||
if (status.phase === "overview") {
|
||
return { main: "Writing KB overview…", count: "", chunk: "", image: "" };
|
||
}
|
||
if (status.phase === "summaries") {
|
||
const folder = status.current_summary ? ` ${status.current_summary}` : "";
|
||
return {
|
||
main: `Summarizing folders…${folder}`,
|
||
count: `(${status.summaries_done}/${status.summaries_total})`,
|
||
chunk: "", // this phase's own (n/m) is its position — no chunk part
|
||
image: "", // the image position belongs to the import phase only
|
||
};
|
||
}
|
||
// null phase (the prelude) or "import" — the phase-64 label
|
||
// shape (since phase 193 its count part reads `N remaining`) +
|
||
// the phase-129
|
||
// chunk part (hidden before the in-flight file's first batch) +
|
||
// the phase-152 image part (hidden before the in-flight PDF's
|
||
// first vision describe call).
|
||
const { main, count } = fmtSyncLabel(
|
||
"sync", status.current_file, status.files_done, status.files_total
|
||
);
|
||
const chunk =
|
||
status.chunks_total > 0
|
||
? `· chunk ${status.current_chunk}/${status.chunks_total}`
|
||
: "";
|
||
const image =
|
||
status.images_total > 0
|
||
? `· image ${status.images_done}/${status.images_total}`
|
||
: "";
|
||
return { main, count, chunk, image };
|
||
}
|
||
|
||
/* Phase 187 (task 03, D4/A4): the run's PEAK-memory clause — the one
|
||
* number the 2026-10-15 incident had to reconstruct from three boots
|
||
* of systemd teardown lines, now on the page that started the run.
|
||
* It is a CLAUSE on the result line that already exists (the
|
||
* fmtSyncResult counts line, "Sync cancelled"), never a new control,
|
||
* never a state string of its own, and never a replacement: the
|
||
* existing copy keeps its exact wording and the clause trails it
|
||
* (" · peak 512 MB"). The value is the status's rss_peak_mb (the
|
||
* process high-water mark, the number the incident was about) or,
|
||
* where only a current reading exists, rss_mb — both PROCESS numbers
|
||
* from app/core/memory.py, never the host's cgroup figure (that one
|
||
* counts page cache; the caveat owns app/core/memory.py).
|
||
* NULL IS THE ABSENCE CONTRACT: both keys null (idle, a run predating
|
||
* this phase, a platform that cannot know) renders NOTHING — never
|
||
* "peak ? MB", never "peak null MB", never 0. The clause rides the
|
||
* SAME string the single box writer (setSyncProgress) lands on
|
||
* #sync-progress-text, so the box — the sole sync text surface AND
|
||
* the sole aria-live region (phase 150) — announces today's wording
|
||
* plus the clause, with no second announcement and no new region. It
|
||
* appears in the TERMINAL renders only (success + cancelled): the
|
||
* running label keeps its byte-identical shape, the failed terminal's
|
||
* box stays empty by the phase-150 contract (the banner + modal carry
|
||
* the error, byte-identical), and the upload settle never has the keys
|
||
* at all — which is the null case again. Text wraps inside the
|
||
* existing line (no clip, no .cell-clip, no layout shift at 375px). */
|
||
function fmtSyncPeakClause(status) {
|
||
const peak = status ? status.rss_peak_mb ?? status.rss_mb : null;
|
||
if (typeof peak !== "number" || !Number.isFinite(peak) || peak < 0) return "";
|
||
return ` · peak ${peak} MB`;
|
||
}
|
||
|
||
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.
|
||
* Phase 128 (task 01): the builders return the PARTS and the single
|
||
* writer (setSyncLabel) lands them — the main part (CSS-ellipsized),
|
||
* the pinned count part (hidden when absent), and the FULL
|
||
* untruncated label on the button title + #sync-progress (A4 — set
|
||
* for EVERY running label; CSS ellipsizes the main part only).
|
||
* Phase 129 (task 04): the third, pinned CHUNK part ("· chunk n/m"
|
||
* — the in-flight file's embed-batch position) is destructured the
|
||
* same way (absent from the bare builders — the upload job and the
|
||
* 202/409 click — so the "" default keeps the part hidden there);
|
||
* phase 152 (task 03) destructures the fourth, IMAGE part ("·
|
||
* image n/m" — the in-flight PDF's vision-image position) the same
|
||
* way (it too rides only the composed full string — no span).
|
||
* Same never-stale mechanics as before (disabled, aria-busy,
|
||
* spinning icon, no is-error). Phase 149 (task 01): the writer call
|
||
* passes `running = true` — the detail box reveals with the full
|
||
* label, and the button's accessible name becomes the full live
|
||
* label (the icon-only running button keeps a name; the writer
|
||
* owns the aria write, the entry no longer sets it) — plus the
|
||
* `is-running` class, which travels with the spin (task 02's CSS
|
||
* makes the running button icon-only).
|
||
*/
|
||
function enterSyncRunningState(kind, currentFile, done, total, status) {
|
||
if (!syncBtn) return;
|
||
syncBtn.disabled = true;
|
||
syncBtn.setAttribute("aria-busy", "true");
|
||
const { main, count, chunk = "", image = "" } =
|
||
kind === "sync" && status
|
||
? fmtSyncPhaseLabel(status)
|
||
: fmtSyncLabel(kind, currentFile, done, total);
|
||
// the parts + the A4 untruncated title / announcer + the phase-149
|
||
// detail box (revealed, full label — phase 152: with the image
|
||
// part when a PDF's vision span is in flight) + the running
|
||
// aria-label
|
||
setSyncLabel(main, count, chunk, true, image);
|
||
syncBtn.classList.remove("is-error");
|
||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||
// Phase 149 (task 01): the icon-only running button — the class
|
||
// and the spin travel together (one in-flight state; the settle
|
||
// removes both).
|
||
syncBtn.classList.add("is-running");
|
||
// Phase 134 (task 02): the Cancel control is visible + enabled
|
||
// ONLY while a SYNC run is in flight — the upload job is NOT
|
||
// cancellable in this phase (its run keeps the control hidden).
|
||
if (kind === "sync") showSyncCancelBtn();
|
||
else hideSyncCancelBtn();
|
||
}
|
||
|
||
function settleSyncButton(label) {
|
||
if (!syncBtn) return;
|
||
// Phase 134 (task 02): EVERY settle hides the Cancel control —
|
||
// every terminal (sync success/failed/cancelled, upload
|
||
// success/failed) and both idle settles pass through here.
|
||
hideSyncCancelBtn();
|
||
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");
|
||
// Phase 149 (task 01): the class travels with the spin — EVERY
|
||
// settle (every terminal + both idles pass through here) leaves
|
||
// the running state.
|
||
syncBtn.classList.remove("is-running");
|
||
// Phase 128 (task 01): the idle / settle labels ("Sync sources",
|
||
// "Synced <stamp>" — the date-qualified stamp, phase 154) go
|
||
// through the parts writer — count hidden
|
||
// (absent), the label's full string rides the title (A4, as with
|
||
// every label state). Phase 150 (task 02): the default
|
||
// `running = false` leaves the detail box alone in every
|
||
// non-running state (the settle paths own the box's result line
|
||
// through setSyncProgress) and re-restores the button's idle
|
||
// accessible name.
|
||
setSyncLabel(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;
|
||
}
|
||
|
||
/* Phase 134 (task 02): the Cancel control's show/hide — visible +
|
||
* enabled ONLY while a SYNC run is in flight (the upload job is NOT
|
||
* cancellable in this phase). Show = unhide + enable + the aria
|
||
* label (the accessible name stays stable — the visible "Cancel"
|
||
* text is the WCAG 2.4 name); hide = hide + disable, so a hidden
|
||
* control is never focusable or activatable in any state (the
|
||
* never-stale contract — the settle paths below are the only hides,
|
||
* and they run on every terminal). */
|
||
function showSyncCancelBtn() {
|
||
if (!syncCancelBtn) return;
|
||
syncCancelBtn.hidden = false;
|
||
syncCancelBtn.disabled = false;
|
||
syncCancelBtn.setAttribute("aria-label", "Cancel sync");
|
||
}
|
||
|
||
function hideSyncCancelBtn() {
|
||
if (!syncCancelBtn) return;
|
||
syncCancelBtn.hidden = true;
|
||
syncCancelBtn.disabled = 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");
|
||
// Phase 150 (task 02): the settle's counts line IS the box's
|
||
// result line (the sole live region) — the string builder is
|
||
// unchanged (A2: the "Synced <stamp>" label stays on the button;
|
||
// the stamp is date-qualified, phase 154). Phase 187 (task 03)
|
||
// APPENDS the peak-memory clause to that same string — the counts
|
||
// line keeps its exact wording, the clause trails it, and a null
|
||
// peak contributes nothing (fmtSyncPeakClause).
|
||
setSyncProgress(fmtSyncResult(status.detail) + fmtSyncPeakClause(status));
|
||
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");
|
||
}
|
||
// Phase 150 (task 02): the error goes to the banner + the modal —
|
||
// the box hides, empty (nothing to announce in the live region).
|
||
setSyncProgress("");
|
||
showSyncError(error);
|
||
emitSyncStatus(status);
|
||
showSyncModal(error);
|
||
}
|
||
|
||
/* Phase 134 (task 02): the cancelled terminal — the NEUTRAL result
|
||
* (A1: cancel is intent, not failure — no error banner, no modal,
|
||
* no error styling): settle the main button retry-ready (which also
|
||
* hides the Cancel control), announce "Sync cancelled" on the
|
||
* aria-live box (#sync-progress — the sole live region, phase
|
||
* 150), hide the error banner (a failed run's banner must not
|
||
* survive the cancel), and emit the status frame (the
|
||
* bor:sync-status detail is the status object, as for every
|
||
* state). Phase 150 (task 02): the settle's "Sync sources" label
|
||
* stays on the button only (the non-running setSyncLabel call no
|
||
* longer writes the box) — the box shows the result line, nothing
|
||
* else. */
|
||
function applySyncCancelled(status) {
|
||
settleSyncButton("Sync sources");
|
||
// The box's result line (phase 150) + the phase-187 peak clause —
|
||
// a cancelled run's memory is still the number the operator asked
|
||
// for ("Sync cancelled · peak 512 MB", nothing when it is null).
|
||
setSyncProgress("Sync cancelled" + fmtSyncPeakClause(status));
|
||
hideSyncError();
|
||
emitSyncStatus(status);
|
||
}
|
||
|
||
function applySyncIdle(status) {
|
||
settleSyncButton("Sync sources");
|
||
// Phase 150 (task 02): the true idle — retry-ready, nothing to
|
||
// announce: the box (the sole live region) hides, empty.
|
||
setSyncProgress("");
|
||
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. sync cancelled: the NEUTRAL terminal (phase 134 task 02 —
|
||
// A1: cancel is intent, not failure — no banner, no modal):
|
||
// the "Sync cancelled" result line + the retry-ready main
|
||
// button; the settle also hides the Cancel control.
|
||
if (syncStatus.state === "cancelled") {
|
||
stopSyncPolling();
|
||
applySyncCancelled(syncStatus);
|
||
return;
|
||
}
|
||
// 6. upload success: settle + catalog refresh (A3 — the upload's
|
||
// counts live on the Sources page; #sync-progress stays
|
||
// empty + hidden).
|
||
if (uploadStatus && uploadStatus.state === "success") {
|
||
stopSyncPolling();
|
||
settleSyncButton("Sync sources");
|
||
setSyncProgress(""); // the upload settle: the box hides (phase 150)
|
||
hideSyncError();
|
||
emitSyncStatus({ state: "idle" });
|
||
loadTree();
|
||
return;
|
||
}
|
||
// 7. 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");
|
||
setSyncProgress(""); // the upload settle: the box hides (phase 150)
|
||
hideSyncError();
|
||
emitSyncStatus({ state: "idle" });
|
||
return;
|
||
}
|
||
// 8. 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 reveals #sync-progress with the same label
|
||
// (phase 150: the box IS the announcer).
|
||
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}).`,
|
||
});
|
||
}
|
||
|
||
/* Phase 134 (task 02): the cancel request — the §7.4 never-stale
|
||
* contract applies exactly as to the main button: the control
|
||
* disables IMMEDIATELY (one request at a time — no double fire)
|
||
* BEFORE the fetch. Phase 150 (task 02): there is no pre-fetch box
|
||
* clear — the run is still in flight at that moment, so blanking
|
||
* would hide the running surface for up to one 2 s poll; the
|
||
* poll's `cancelled` branch settles the box, and the control's
|
||
* immediate disable keeps the never-stale contract.
|
||
* • 403 → the whoami-backstop branch (stop polling, hide the
|
||
* main button, settle idle) — the same depth as every
|
||
* other 403 in this module;
|
||
* • 409 → NO-OP: the run ended between the tick and the click —
|
||
* the poll settles on the next tick (its settle hides
|
||
* the control); no error copy, no state change;
|
||
* • 202 → nothing: the control stays disabled — the poll's
|
||
* `cancelled` branch settles everything (the run may
|
||
* already be terminal by the next tick — the status
|
||
* object, not the request, is authoritative);
|
||
* • any other non-2xx / network failure → the phase-55 neutral
|
||
* retry copy on #sync-progress (the box) + the control
|
||
* re-enables (the run is still going — the owner can retry). */
|
||
async function requestCancel() {
|
||
if (!syncCancelBtn) return;
|
||
syncCancelBtn.disabled = true; // FIRST — never stale, one request at a time
|
||
// Phase 150 (task 02): the pre-fetch clear is dropped — a NO-OP on
|
||
// the box. The run is still in flight at this moment, so blanking
|
||
// would hide the running surface for up to one 2 s poll; the
|
||
// poll's `cancelled` branch settles it. The never-stale contract
|
||
// is kept by the control's immediate disable above.
|
||
let r;
|
||
try {
|
||
r = await fetch("/api/sync/cancel", { method: "POST" });
|
||
} catch {
|
||
// Network failure: the run is still going — the phase-55
|
||
// neutral retry copy + the control re-enables (retryable).
|
||
setSyncProgress("Couldn't cancel the sync — try again.");
|
||
syncCancelBtn.disabled = false;
|
||
return;
|
||
}
|
||
if (r.status === 403) {
|
||
stopSyncPolling();
|
||
if (syncBtn) syncBtn.hidden = true;
|
||
applySyncIdle();
|
||
return;
|
||
}
|
||
if (r.status === 409) return; // race: the run ended — the poll settles
|
||
if (r.ok) return; // 202: the poll's cancelled branch settles everything
|
||
setSyncProgress("Couldn't cancel the sync — try again.");
|
||
syncCancelBtn.disabled = false; // the run is still going — retry
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
// Phase 134 (task 02): a reload during/right after a cancel shows
|
||
// the neutral result — never a stuck running state.
|
||
if (status.state === "cancelled") {
|
||
applySyncCancelled(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();
|
||
}
|
||
// Phase 134 (task 02): the Cancel control — armed next to the main
|
||
// button (the control is visible only while a sync run is in
|
||
// flight, so it can never be reached in any other state).
|
||
if (syncCancelBtn) {
|
||
syncCancelBtn.addEventListener("click", requestCancel);
|
||
}
|
||
|
||
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
|
||
|
||
/* Phase 167 (task 04, D4/A3 — the TODO L5 2 MB fix): the client no
|
||
longer holds the whole tree. `levelCache` marks the folder levels
|
||
the drill has already fetched (key: source + "/" + the
|
||
source-relative folder path); the merged children live ON THE
|
||
NODES (Object.assign in fetchLevel), so currentLevelNode() and
|
||
renderLevel() keep their pre-phase semantics for every loaded
|
||
level. `drillPending` is the in-flight drill { source, folder } —
|
||
a second drill click is a no-op while a node fetch runs (§7.4:
|
||
every settle path re-enables the controls). `navSeq` bumps on
|
||
every applyTarget so a navigation invalidates an in-flight drill
|
||
exactly like a newer load does. A NEW tree load clears the cache
|
||
(the sync/refresh call sites keep working unchanged) and any
|
||
in-flight drill with it. */
|
||
const levelCache = new Map();
|
||
let drillPending = null;
|
||
let drillRowEl = null; // the ONE in-flight row (loading OR alert)
|
||
let navSeq = 0;
|
||
|
||
async function loadTree() {
|
||
const my = ++loadSeq;
|
||
drillPending = null; // any in-flight drill is invalidated by this load
|
||
let r;
|
||
try {
|
||
r = await fetch("/api/docs/tree?depth=1");
|
||
} 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: [] };
|
||
levelCache.clear(); // a fresh tree invalidates every drilled level
|
||
resetVanishedLocation();
|
||
// Phase 167: the user's own level may be one the fresh depth-1
|
||
// payload only NAMES (truncated, cache just cleared) — rehydrate
|
||
// it through the node route BEFORE it paints, so a refresh never
|
||
// shows a half-empty level.
|
||
if (needsLevelFetch(currentLevelNode(), current.source)) {
|
||
enterFolder({ source: current.source, folder: current.folder }, null, false);
|
||
return;
|
||
}
|
||
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. Since
|
||
* phase 167 the check is folderKnownIn (what the HELD levels prove)
|
||
* — the fetch path's own vanished-signal is the node route's 404. */
|
||
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 && !folderKnownIn(src, current.folder)) {
|
||
current = { source: null, folder: null };
|
||
}
|
||
}
|
||
|
||
/* Phase 167 (task 04, D4): the client no longer holds the whole
|
||
* tree, so the phase-97 full-tree existence walk becomes KNOWN-ness:
|
||
* a folder is known iff every node of its ancestor chain exists in
|
||
* what the client holds (the depth-1 tree + the drilled levels —
|
||
* merged children ride the nodes, so one walk over the held children
|
||
* answers). A broken chain means the folder is gone OR simply not
|
||
* loaded that deep — for the never-stale RESET both are honestly
|
||
* "back to the top level". The drill path does not guess: the node
|
||
* route's 404 IS the "the folder vanished" signal (fetchLevel). */
|
||
function folderKnownIn(src, folderPath) {
|
||
if (folderPath === "") return true; // the source root always exists
|
||
let node = src;
|
||
let acc = "";
|
||
for (const part of folderPath.split("/")) {
|
||
acc = acc ? acc + "/" + part : part;
|
||
const next = (node.children || []).find(
|
||
(c) => c.kind === "folder" && c.path === acc
|
||
);
|
||
if (!next) return false;
|
||
node = next;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/* The node for a drill target (phase 97, phase 167-generalized):
|
||
* the source node at the root, or the folder node found by walking
|
||
* the HELD levels 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; a truncated node simply has no
|
||
* children to walk into). */
|
||
function nodeForTarget(target) {
|
||
const src = kbTree.sources.find((s) => s.name === target.source);
|
||
if (!src) return null;
|
||
if (target.folder === null || target.folder === "") return src;
|
||
let node = src;
|
||
let acc = "";
|
||
for (const part of target.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;
|
||
}
|
||
|
||
function currentLevelNode() {
|
||
return nodeForTarget(current);
|
||
}
|
||
|
||
/* The KB-wide stat cards. Phase 167 (task 04, D2/D4): the server's
|
||
* `totals` (computed from the FULL catalogue rows beside any depth
|
||
* of tree) are the source of truth — a depth-limited tree CANNOT be
|
||
* summed client-side. The phase-97 recursive walk (document count,
|
||
* chunks sum, max indexed_at — the values identical to the former
|
||
* flat /api/docs walk) stays ONLY as the defensive fallback for a
|
||
* tree served without `totals` (a pre-phase-167 process) — the
|
||
* no-crash path. fmtDate reuse for the max (renderLevel). */
|
||
function treeStats() {
|
||
const totals = kbTree.totals;
|
||
if (totals && typeof totals === "object") {
|
||
return {
|
||
docs: totals.documents,
|
||
totalChunks: totals.chunks,
|
||
last: totals.last_indexed || "",
|
||
};
|
||
}
|
||
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". No URL change (the navigation is a
|
||
* re-render); phase 167: an ancestor is already loaded (you cannot
|
||
* drill past it without holding it), so a breadcrumb step costs no
|
||
* fetch in practice — goTo stays the ONE drill code either way. */
|
||
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, a);
|
||
});
|
||
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 (phase 99, task 02, D2 — the drill state IS
|
||
* the history state; since phase 167 a user drill may FIRST fetch
|
||
* the level — see enterFolder). `applyTarget(target, push)`: when `push` is true the target DIFFERS from the current
|
||
* level (a re-click of the current row/segment pushes NO duplicate
|
||
* entry), it FIRST records a STATE-ONLY entry — history.pushState(
|
||
* { view: "rag", kb: target }, "") — the second arg "" + no third:
|
||
* the URL stays the shell's pathname (no new route, the phase-76
|
||
* deep-link surface is untouched) — then sets `current` +
|
||
* re-renders. Back/forward (popstate) and the re-show alignment
|
||
* (bor:view-refresh) adopt with push=false: they never push (the
|
||
* browser owns those entries). */
|
||
function applyTarget(target, push) {
|
||
if (push && (target.source !== current.source || target.folder !== current.folder)) {
|
||
history.pushState({ view: "rag", kb: target }, "");
|
||
}
|
||
current = { source: target.source, folder: target.folder };
|
||
navSeq++; // phase 167: any navigation invalidates an in-flight drill
|
||
renderLevel();
|
||
}
|
||
|
||
/* Every user-initiated drill (source row, folder link, breadcrumb
|
||
* segment) — push a history entry, then drill (D2, phase 99 task
|
||
* 02). Since phase 167 (task 04, A3) the drill may FIRST fetch the
|
||
* level (enterFolder); the push lands when the level is in hand, so
|
||
* a failed drill records nothing. `link` is the clicked link — it
|
||
* carries the in-flight aria-busy/aria-disabled state. */
|
||
function goTo(target, link) {
|
||
enterFolder(target, link, true);
|
||
}
|
||
|
||
/* Phase 167 (task 04, D4/A3) — the lazy drill. The depth-1 tree
|
||
* NAMES deeper folders (truncated: true, children not built); the
|
||
* FIRST open of one fetches its level through the node route, merges
|
||
* the children into the node, and caches the level — a re-open is
|
||
* free (zero network). A level the client already holds (or a node
|
||
* the payload fully built) drills with NO fetch, exactly as before:
|
||
* applyTarget + render, nothing else. One drill at a time —
|
||
* drillPending is the §7.4 double-click guard, and every settle
|
||
* path (success, failure, 404, superseded) releases it. */
|
||
function needsLevelFetch(node, source) {
|
||
return (
|
||
!!node &&
|
||
node.truncated === true &&
|
||
!levelCache.has(source + "/" + node.path)
|
||
);
|
||
}
|
||
|
||
async function enterFolder(target, link, push) {
|
||
if (drillPending) return; // one drill at a time (§7.4 double-click guard)
|
||
const node = nodeForTarget(target);
|
||
if (needsLevelFetch(node, target.source)) {
|
||
drillPending = { source: target.source, folder: target.folder };
|
||
const loaded = await fetchLevel(target, node, link);
|
||
if (!loaded) return; // failed / vanished / superseded — handled inside
|
||
}
|
||
applyTarget(target, push);
|
||
}
|
||
|
||
/* ONE node fetch + its full §7.4 lifecycle. The fetch URL is
|
||
* /api/docs/tree/node?source=…&path=… (path source-relative; the
|
||
* route is documented in app/api/docs.py — one catalogue query per
|
||
* drill, no N+1). The race convention EXTENDS from loadTree: the
|
||
* response may only paint while NO newer load (loadSeq, phase 79)
|
||
* and NO navigation (navSeq) have happened — a stale response never
|
||
* touches the DOM. Success: Object.assign merges the node (children,
|
||
* counts, dates, summary, pending flags — the wire shape the full
|
||
* tree produces) and levelCache marks the level; only a 200 caches.
|
||
* A 404 IS the vanished-folder signal — honest reset to the top
|
||
* level, nothing cached. Any other failure: the alert + Retry row,
|
||
* the current level staying rendered. */
|
||
async function fetchLevel(target, targetNode, link) {
|
||
const my = loadSeq;
|
||
const nav = navSeq;
|
||
if (link) {
|
||
link.setAttribute("aria-busy", "true");
|
||
link.setAttribute("aria-disabled", "true"); // a double click cannot refetch
|
||
}
|
||
showDrillLoading(target.folder.split("/").pop());
|
||
let response = null;
|
||
let vanished = false;
|
||
try {
|
||
response = await fetch("/api/docs/tree/node?source=" + encodeURIComponent(target.source) + "&path=" + encodeURIComponent(target.folder));
|
||
if (response.status === 404) vanished = true;
|
||
} catch {
|
||
response = null; // network failure — the alert path below
|
||
}
|
||
// Every settle path re-enables the controls (§7.4) — the link is
|
||
// only ours to clear while this drill is still THE pending one (a
|
||
// newer load owns the flags the moment it starts).
|
||
if (drillPending && drillPending.source === target.source && drillPending.folder === target.folder) {
|
||
drillPending = null;
|
||
}
|
||
if (link) {
|
||
link.removeAttribute("aria-busy");
|
||
link.removeAttribute("aria-disabled");
|
||
}
|
||
if (my !== loadSeq || nav !== navSeq) return false; // superseded — never paints
|
||
clearDrillRow();
|
||
if (vanished) {
|
||
// The folder vanished under us (pruned/unregistered since the
|
||
// level was listed) — the phase-97 never-stale contract: reset
|
||
// to the top level, no stale breadcrumb, nothing cached.
|
||
current = { source: null, folder: null };
|
||
renderLevel();
|
||
return false;
|
||
}
|
||
if (response === null || !response.ok) {
|
||
showDrillFailure(target, link); // the current level stays rendered
|
||
return false;
|
||
}
|
||
const merged = await response.json();
|
||
if (my !== loadSeq || nav !== navSeq) return false; // stale — never paints
|
||
Object.assign(targetNode, merged);
|
||
levelCache.set(target.source + "/" + target.folder, true);
|
||
return true;
|
||
}
|
||
|
||
/* The ONE in-flight row of the folders panel — appended to the
|
||
* level the user is LEAVING (renderLevel's tbody clear owns it: a
|
||
* re-render always drops the row). Tokens-only .kb-level-loading
|
||
* (styles.css); textContent only, the house rule. */
|
||
function clearDrillRow() {
|
||
if (drillRowEl) {
|
||
drillRowEl.remove();
|
||
drillRowEl = null;
|
||
}
|
||
}
|
||
|
||
function showDrillLoading(name) {
|
||
clearDrillRow();
|
||
const row = document.createElement("tr");
|
||
row.className = "kb-level-loading";
|
||
const td = document.createElement("td");
|
||
td.colSpan = 4;
|
||
const note = document.createElement("span");
|
||
note.setAttribute("role", "status");
|
||
note.textContent = "Loading " + name + "…"; // never innerHTML
|
||
td.appendChild(note);
|
||
row.appendChild(td);
|
||
drillRowEl = row;
|
||
if (foldersWrap) foldersWrap.hidden = false; // the row needs its panel
|
||
if (foldersTbody) foldersTbody.appendChild(row);
|
||
}
|
||
|
||
/* Phase 167: rehydrate the CURRENT level when the held tree only
|
||
* names it — used by the ADOPTED navigations (popstate: the popped
|
||
* entry can point at a level the client has not fetched since the
|
||
* last load; the bor:view-refresh alignment is covered by loadTree's
|
||
* own rehydration). A no-op for held levels and at the top. */
|
||
function ensureCurrentLevel() {
|
||
if (drillPending) return;
|
||
if (needsLevelFetch(currentLevelNode(), current.source)) {
|
||
enterFolder({ source: current.source, folder: current.folder }, null, false);
|
||
}
|
||
}
|
||
|
||
function showDrillFailure(target, link) {
|
||
clearDrillRow();
|
||
const row = document.createElement("tr");
|
||
row.className = "kb-level-loading";
|
||
const td = document.createElement("td");
|
||
td.colSpan = 4;
|
||
const note = document.createElement("span");
|
||
note.setAttribute("role", "alert");
|
||
note.textContent =
|
||
"Couldn't load " + target.folder.split("/").pop() + " — "; // never innerHTML
|
||
const retry = document.createElement("button");
|
||
retry.type = "button"; // a REAL button (the phase-49 .retry-btn pill)
|
||
retry.className = "retry-btn";
|
||
retry.textContent = "Retry";
|
||
retry.addEventListener("click", () => enterFolder(target, link, true));
|
||
td.append(note, retry);
|
||
row.appendChild(td);
|
||
drillRowEl = row;
|
||
if (foldersWrap) foldersWrap.hidden = false;
|
||
if (foldersTbody) foldersTbody.appendChild(row);
|
||
}
|
||
|
||
/* 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: "" }, link);
|
||
});
|
||
nameTd.appendChild(link);
|
||
tr.appendChild(nameTd);
|
||
const countTd = document.createElement("td");
|
||
countTd.textContent = String(s.documents);
|
||
tr.appendChild(countTd);
|
||
// Phase 106 (task 08, D8/D9): the Updated cell — the source's
|
||
// subtree MAX document created_at (the tree API derives it),
|
||
// BETWEEN the count and the Description (the owner's verbatim
|
||
// position). A 0-document source has none → the statLast "–" null
|
||
// idiom; the ISO value rides the title (hover precision — the
|
||
// makeRow path-cell idiom).
|
||
const updatedTd = document.createElement("td");
|
||
updatedTd.textContent = s.updated_at ? fmtDate(s.updated_at) : "–";
|
||
if (s.updated_at) updatedTd.title = s.updated_at; // full ISO on hover
|
||
tr.appendChild(updatedTd);
|
||
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 }, link);
|
||
});
|
||
nameTd.appendChild(link);
|
||
tr.appendChild(nameTd);
|
||
const countTd = document.createElement("td");
|
||
countTd.textContent = String(f.documents);
|
||
tr.appendChild(countTd);
|
||
// Phase 106 (task 08, D8/D9): the Updated cell — the folder's
|
||
// subtree MAX document created_at (the tree API derives it),
|
||
// BETWEEN the count and the Description (the owner's verbatim
|
||
// position); the ISO value rides the title (hover precision — the
|
||
// makeRow path-cell idiom). The "–" branch is defensive: a folder
|
||
// node always has ≥1 document, so its max is never null (a
|
||
// 0-document source IS the null case, makeSourceRow).
|
||
const updatedTd = document.createElement("td");
|
||
updatedTd.textContent = f.updated_at ? fmtDate(f.updated_at) : "–";
|
||
if (f.updated_at) updatedTd.title = f.updated_at; // full ISO on hover
|
||
tr.appendChild(updatedTd);
|
||
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,
|
||
created_at: f.created_at, // phase 106 (task 08, D8): the tree's file date
|
||
indexed_at: f.indexed_at,
|
||
// Phase 122 (task 04): the image affordance — the tree's
|
||
// file node carries is_image / image_url / summary on an
|
||
// image doc (the omission rule: a TEXT node's wire shape
|
||
// carries none, so these stay undefined there and makeRow
|
||
// keeps the bare-link cell, byte-identical to pre-phase).
|
||
is_image: f.is_image,
|
||
image_url: f.image_url,
|
||
summary: f.summary,
|
||
})
|
||
);
|
||
}
|
||
if (tableWrap) tableWrap.hidden = files.length === 0;
|
||
}
|
||
|
||
/* Phase 122 (task 04): the document glyph — the fallback INSIDE the
|
||
* fixed thumbnail box (a failed fetch, or a node with no servable
|
||
* image_url). Static SVG, aria-hidden (the img's alt is the
|
||
* accessible content; this is decoration for the box). Built with
|
||
* createElementNS — the module keeps its ONE innerHTML (the static
|
||
* sync-modal skeleton, the test_kb_tree_ui pin). */
|
||
function docThumbGlyph() {
|
||
const span = document.createElement("span");
|
||
span.className = "kb-doc-thumb-glyph";
|
||
span.setAttribute("aria-hidden", "true");
|
||
const NS = "http://www.w3.org/2000/svg";
|
||
const svg = document.createElementNS(NS, "svg");
|
||
svg.setAttribute("viewBox", "0 0 48 48");
|
||
svg.setAttribute("fill", "none");
|
||
svg.setAttribute("stroke", "currentColor");
|
||
svg.setAttribute("stroke-width", "2.4");
|
||
svg.setAttribute("stroke-linecap", "round");
|
||
svg.setAttribute("stroke-linejoin", "round");
|
||
const sheet = document.createElementNS(NS, "path");
|
||
sheet.setAttribute(
|
||
"d",
|
||
"M12 4h16l8 8v28a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z"
|
||
);
|
||
const fold = document.createElementNS(NS, "path");
|
||
fold.setAttribute("d", "M28 4v8h8");
|
||
svg.append(sheet, fold);
|
||
span.appendChild(svg);
|
||
return span;
|
||
}
|
||
|
||
/* Phase 122 (task 04): the image-doc thumbnail — the FIXED 48px box
|
||
* (object-fit: cover via CSS, loading="lazy", alt = the summary —
|
||
* the vision description; a NULL/blank summary falls back to the
|
||
* title, then the path). A failed fetch swaps in the document
|
||
* glyph in the SAME box (progressive enhancement — no layout shift
|
||
* beyond the fixed box, no broken-image placeholder). Called only
|
||
* for image rows (makeRow gates on d.is_image). */
|
||
function docThumb(d) {
|
||
const box = document.createElement("span");
|
||
box.className = "kb-doc-thumb";
|
||
const alt =
|
||
typeof d.summary === "string" && d.summary.trim() !== ""
|
||
? d.summary
|
||
: d.title || d.path;
|
||
if (d.image_url) {
|
||
const img = document.createElement("img");
|
||
img.className = "kb-doc-thumb-img";
|
||
img.loading = "lazy";
|
||
img.src = d.image_url;
|
||
img.alt = alt;
|
||
img.addEventListener("error", () => box.replaceChildren(docThumbGlyph()));
|
||
box.appendChild(img);
|
||
} else {
|
||
box.appendChild(docThumbGlyph());
|
||
}
|
||
return box;
|
||
}
|
||
|
||
/* 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;
|
||
// Phase 122 (task 04): an image row gets the FIXED 48px thumbnail
|
||
// box before the path link (the .kb-doc-path flex wrapper — the
|
||
// link keeps its ellipsis). Text rows keep the bare-link cell,
|
||
// byte-identical to pre-phase (no box at all). Phase 181 (task
|
||
// 04, design D7): verified NON-CHANGE for SVG rows — the thumb
|
||
// loads the same extension-less image_url and .kb-doc-thumb-img
|
||
// pins the img to the fixed 48px box (width/height 100% +
|
||
// object-fit: cover), so a size-less viewBox-only vector fills
|
||
// the box like a raster; no format sniffing needed here either.
|
||
if (d.is_image) {
|
||
const pathWrap = document.createElement("div");
|
||
pathWrap.className = "kb-doc-path";
|
||
pathWrap.append(docThumb(d), link);
|
||
pathTd.appendChild(pathWrap);
|
||
} else {
|
||
pathTd.appendChild(link);
|
||
}
|
||
tr.appendChild(pathTd);
|
||
|
||
// Phase 106 (task 08, D8): the cell order is [title, chunks,
|
||
// created, indexed] — the Created cell lands BEFORE Indexed (the
|
||
// owner's verbatim position). The date cells are built EXPLICITLY
|
||
// (the plain-td loop can't carry per-cell titles): textContent is
|
||
// the locale date (fmtDate — the Indexed idiom), and the Created
|
||
// cell carries the FULL ISO value as its title (hover precision —
|
||
// the path-cell idiom; the E2E asserts on the locale-stable title,
|
||
// not on the toLocaleString output).
|
||
for (const value of [d.title, String(d.chunks)]) {
|
||
const td = document.createElement("td");
|
||
td.textContent = value;
|
||
tr.appendChild(td);
|
||
}
|
||
const createdTd = document.createElement("td");
|
||
createdTd.textContent = fmtDate(d.created_at);
|
||
createdTd.title = d.created_at; // full ISO on hover (locale-stable)
|
||
tr.appendChild(createdTd);
|
||
const indexedTd = document.createElement("td");
|
||
indexedTd.textContent = fmtDate(d.indexed_at);
|
||
tr.appendChild(indexedTd);
|
||
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) + phase 99 (task 02,
|
||
D2): a user-initiated re-show of this already-mounted view
|
||
makes the router dispatch bor:view-refresh on the section —
|
||
FIRST align the drill state with the history state (a
|
||
kb-carrying entry KEEPS the drill — the active-link re-click
|
||
pushed nothing, the drilled entry is still on top; a kb-less
|
||
entry — a fresh nav visit — starts at the TOP level), THEN
|
||
re-load the catalog tree (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", () => {
|
||
const kb = history.state && history.state.kb;
|
||
if (kb) applyTarget(kb, false);
|
||
else applyTarget({ source: null, folder: null }, false);
|
||
loadTree();
|
||
});
|
||
/* Phase 99 (task 02, D2): the browser back/forward walks the
|
||
breadcrumb — a popstate ADOPTS the popped entry's kb via the
|
||
no-push variant (the browser owns its entries — adopt/reset
|
||
never push) or, for a kb-LESS entry (the boot entry, the
|
||
router's view entries, any foreign state), RESETS the drill to
|
||
the TOP level. The router's own popstate (registered earlier,
|
||
at shell boot) reads the pathname only: rag-internal entries
|
||
never change it, so its listener stays a no-op and THIS
|
||
listener's render is the visible one; for a foreign entry it
|
||
has already switched the visible view, so this render is a
|
||
harmless hidden one. Back at the top level pops the router's
|
||
view entry — the view leaves, exactly as today. Armed ONCE
|
||
here, admin branch only: the anonymous gate installs no
|
||
listeners. */
|
||
window.addEventListener("popstate", (event) => {
|
||
const kb = event.state && event.state.kb;
|
||
if (kb) applyTarget(kb, false);
|
||
else applyTarget({ source: null, folder: null }, false);
|
||
// Phase 167 (task 04): a forward/back can adopt an entry whose
|
||
// level the client has not fetched since — rehydrate it through
|
||
// the node route (a no-op for held levels; a truly vanished one
|
||
// takes the 404-reset / alert path inside).
|
||
ensureCurrentLevel();
|
||
});
|
||
loadTree();
|
||
}
|