Files
brain-of-reese/frontend/assets/git-sources.js
T
ducoterra 4f20c256d7
Build and Push Containers / build-and-push-app (push) Successful in 7m24s
Build and Push Containers / build-and-push-db (push) Successful in 36s
phase: 191_source_name_labels_tags
All gates re-verified against a stable tree. Report:

**Verified (all 6 tasks in `complete/`; this was the final pass)**
- `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`.
- **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness.
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned).
- **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code.
- **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria.
- **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
2026-10-19 06:13:32 -04:00

2074 lines
98 KiB
JavaScript

/* Brain of Reese — Sources view (the git-sources manager; phase 35
* task 04; archive uploads, phase 49 task 03; phase 76 task 02: shell
* view module — formerly the standalone git-sources.html).
*
* The view module for /git-sources.html: the admin-only manager for
* the stored source list (git-sources table, phase 35 tasks 01/02) —
* git repo URLs (kind "git") and uploaded archives unpacked under
* BOR_UPLOAD_DIR (kind "local", phase 49; the phase-38
* local-directory form is gone — the kind=local API POST is
* unchanged, the page just no longer offers it).
* This module is the single owner of the view's behaviour:
*
* Phase 76 (task 02) — shell view module (the "Sources" view of the
* ONE-document shell; /git-sources.html now serves the shell, and
* assets/router.js lazy-imports THIS module on first show):
*
* • the top-level boot is now `export async function mount(root)` —
* root is the view's <section id="view-git-sources">, and every
* DOM lookup scopes to root (the view ids stay unique across the
* shell — scoped lookups keep the module honest and testable).
* The router mounts a view ONCE (mount-once, hide-forever), so
* the bindings and the upload-progress state machine survive
* every switch: the upload poller is a self-chaining setTimeout
* started when an upload begins (never at boot), so progress
* continues while the user is on another view, and nothing
* refetches on re-show. The single toast node/timer stay module
* scope (one per document life — toasts never stack).
* • the initSharedHeader() call is DROPPED: in the shell the shared
* header boots exactly once, via the chat module (app.js) at shell
* boot — the view never re-boots it. The gate keeps fetchIsAdmin()
* — the SAME cached /api/whoami promise header.js exports (zero
* extra requests).
*
* • boot (the mount's tail) — fetchIsAdmin() (the cached whoami):
* anonymous → the sign-in gate shows and the manager stays hidden
* (the exact Sources page gate pattern, and NO /api/git-sources
* call is made); admin → gate hidden, #git-sources-content
* revealed, loadSources().
*
* • loadSources() — GET /api/git-sources → the table rows
* (#git-sources-tbody), the env-fallback note's visibility
* (from_env), and the empty state. Each row leads with its kind
* badge (Git/Local — text + color, never color alone) plus the
* location in a mono <code>: the git URL, or the full local path
* for kind "local" rows (phase 38). Values are ALWAYS rendered
* with textContent — never innerHTML (URLs may embed user:pass@
* credentials; phase 32's masking discipline). Non-2xx or a
* network failure renders the role="alert" load error with a
* retry button — never a stuck page.
* • add — #git-source-form submit → POST /api/git-sources {url}.
* The §7.4 never-stale lifecycle (wireAddForm): the button
* disables + relabels "Adding…" while the request is out,
* re-enables on success AND failure. 201 clears the input,
* reloads the list, and focuses the new row's Remove button
* (a11y); a failure (409 duplicate, 422 validation) shows the
* server detail inline under the form (role="alert", 422
* shape-aware like the tuning forms) and keeps the input — the
* instruction survives. 409/422 details are fixed generic strings
* (credential safety — the URL is never echoed).
* • upload — #archive-upload-form submit (phase 49, reworked to the
* phase-64 202 contract in task 05, unpack-only in phase 90 —
* the phase-49 synchronous 200 paragraph is superseded): POST
* /api/git-sources/upload with a FormData file (NO manual
* Content-Type — the browser sets the multipart boundary). The
* §7.4 never-stale lifecycle keeps its shape — the button
* ("Upload") disables + relabels "Uploading…" while the request
* is out — but the transfer is short: the 202 arrives the moment
* the archive is safely on disk (A1). 202 → the page-local
* "Successfully uploaded — <file>" toast fires (showUploadToast,
* the phase-55 share-toast pattern; A2: safe to navigate away),
* the file input clears, and the button hands over to the
* background run — the processing state (bare "Processing…",
* disabled, title cleared) plus startUploadPolling(): a 2 s poll
* of GET /api/git-sources/upload/status that renders the bare
* "Processing…" label for the WHOLE run (phase 90, A2 — the run
* is unpack + register only: no file, no "(n/m)" counts, no
* title) and settles it: success → the ready-for-sync line
* ("Uploaded <name> (<stamp>) — press Sync sources to import
* it." — the phase-154 completion stamp, fmtUploadStamp off the
* status's finished_at, the A1 date-qualified form cited in
* sources.js's fmtSyncTime, not restated; a running / partial
* payload without timestamps renders the byte-identical pre-phase
* line — fmtUploadResult off the status's {"message": "uploaded"}
* detail — the role=status result line) + the "Archive uploaded
* — press Sync sources to import it." announce + loadSources
* (the new/updated row lands with the Local badge; a re-upload
* refreshes the row — no duplicate; NO second toast — A2);
* failure → the sanitized server error in the role=alert banner
* + loadSources, the file selection KEPT for a one-click
* re-upload. 409 (an upload is already in progress) raises NO
* error banner — it re-attaches to the in-flight run (processing
* state + poll, never stale). Other non-2xx (422 format/name,
* 413 cap, 5xx) keep the phase-49 error banner + the kept file
* selection. The submit finally restores the button ONLY when no
* poll is active (§7.4). Boot re-attach (initUploadStatus, admin
* branch): a running run re-enters the processing state + poll
* (a reload mid-run re-attaches — no second upload), a terminal
* run re-renders its result line / error banner (the safe name
* was page-local — lastUploadName is null after a reload — so the
* re-rendered line is the nameless "Uploaded <stamp> — press
* Sync sources to import it." — the stamp comes from the SERVER
* payload, so the line is self-dating even with the name
* unknown, phase 154).
* • remove — a row's Remove button opens the page-local
* confirmation modal (#remove-confirm-dialog, a real
* role="alertdialog" — the native confirm() retired, phase 69):
* it
* names the source (#remove-confirm-source, textContent ONLY —
* URLs may embed user:pass@ credentials, phase 32) and states
* the full-removal policy — the row, the source's indexed
* documents (chunks + embeddings), and, for git clones and
* uploaded archives, the files on the server's disk, all removed
* immediately by the server-side DELETE. Cancel is the safe
* default: focus lands on Cancel at open; Escape, the Cancel
* button, and the dim backdrop all close as CANCEL (no request —
* focus returns to the row's Remove button). "Remove source" runs
* the §7.4 in-flight lifecycle IN the modal: both buttons
* disable + the confirm relabels "Removing…" while the DELETE is
* out — the in-flight window covers the whole server-side
* cleanup (DB prune → file removal → best-effort overview
* refresh; a slow LLM refresh is expected, not a stuck button —
* the same "wait for the terminal state" pattern as the
* Sync/Upload processing states). Navigating away mid-removal is
* not recommended: the row + index commit first, so the KB stays
* consistent; a rare interrupted file step leaves an inert orphan
* dir (no row → never imported again). 204 → close (focus
* return), loadSources(), then announce — the removal
* confirmation is the LAST announcement, so the reload's "N
* sources listed." cannot overwrite it (the announcer is the
* screen-reader confirmation for the destructive action);
* non-2xx → the in-modal role="alert" line (the server detail,
* apiDetail) + both
* buttons re-enabled + the confirm relabeled "Remove source" (the
* dialog stays open — the fix is one retry, not a re-search for
* the row); network failure → the fixed "is the app reachable?"
* line, same restore. Env-fallback rows (id null — the list
* comes from BOR_GIT_SOURCES, not the table) carry no Remove:
* nothing is stored to remove — they show a "from .env" tag
* instead.
* • announce(msg) — #git-sources-announcer (role=status,
* aria-live=polite): the screen-reader confirmation for loads,
* adds, and removals.
*
* 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 Sources 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 `loadSources()`, whose re-call
* resets all three list states: the populated render (renderSources
* replaces the tbody + re-syncs the empty state) and the load error
* (hideLoadError() runs on the success path, so an error followed by
* a successful refresh clears it). The listener is armed only in the
* ADMIN branch, after the whoami gate passes: anonymous shows the
* gate and never fetches /api/git-sources (the Sources-page gate
* pattern).
*
* Phase 89 (task 05) — the per-source ignore-paths editor: every
* STORED row (s.id truthy — env-fallback rows carry NO button, A3:
* nothing is stored to edit) gets an "Ignore paths" button in the
* actions cell (left of Remove) that opens the page-local alertdialog
* (#ignore-editor-dialog — the EXACT #remove-confirm-dialog pattern,
* phase 69): #ignore-editor-source names the source (textContent ONLY
* — the same `value` expression makeRow uses — URLs may embed
* user:pass@ credentials, phase 32), the mono textarea prefills the
* row's stored list (one path per line — the phase-89 A1 prefix rule,
* stated in plain words in the helper copy), and focus lands on
* Cancel (the safe default); Escape / Cancel / the dim backdrop close
* as CANCEL (no request — focus returns to the row's trigger button).
* "Save" runs the §7.4 in-flight lifecycle: both buttons disable +
* the save relabels "Saving…" while the PATCH
* /api/git-sources/{id} is out (blank lines in the box are separators,
* not entries — split + trim + drop empty client-side; the server
* still rejects empty entries defensively, A4). 200 → close (focus
* return), loadSources (the row's "N ignored" count tag lands — the
* A5 replace semantics round-trip through GET), announce — the update
* confirmation is the LAST announcement (the reload's "N sources
* listed." must not overwrite it); non-2xx → the in-dialog
* role="alert" line (the server detail, apiDetail 422-shape-aware) +
* the buttons restored, the dialog STAYS open and the textarea
* content is KEPT (the instruction survives — tuning-form
* convention); network failure → the fixed reachable? line, same
* restore. Rows whose list is non-empty show the "N ignored" count
* tag next to the location <code> (text + a distinct background —
* never color alone, WCAG 1.4.1).
*
* Phase 105 (task 05) — the per-row "Hidden" toggle: every STORED
* row (s.id truthy — env-fallback rows get NO checkbox, A3: nothing
* is stored to flag) gets a native labeled checkbox (visible
* "Hidden" text + the checkbox's own aria-label naming the full
* source value — setAttribute, never innerHTML) in the actions
* cell, LEFT of the "Ignore paths" button (Remove stays last).
* Checked state comes ONLY from the server row (s.include_hidden —
* makeRow renders server state, never a prior local flip). When on,
* the source cell shows a "hidden on" text tag (text + background,
* never color alone — WCAG 1.4.1). Flipping runs the §7.4
* never-stale lifecycle (toggleHidden): the box disables at once
* (no double-flip), PATCH /api/git-sources/{id} with ONLY
* { include_hidden } (task 03's optional field — the row's ignore
* list is untouched); 200 → the error line clears, loadSources()
* (the row re-renders from the server, the "hidden on" tag lands)
* and the confirmation is the LAST announcement (the reload's "N
* sources listed." lands first — the phase-89 order); failure →
* the box REVERTS to the server state and re-enables, and the
* detail lands in the page-level #git-sources-hidden-error line
* (role=alert — the checkbox is a table-cell control, so its error
* is at page level; the ignore dialog carries its own INSIDE the
* modal). A healed load clears the line (loadSources' success path
* calls hideHiddenError — the phase-89 "happy path heals the error
* state" precedent).
*
* Phase 121 (task 03) — the masked token field (LOCKED A2): the add
* form gains the optional `#git-source-token` (type=password,
* autocomplete=off — a PAT is not a site credential) with the visible
* "optional — private repos" hint; the submit body is
* `(url, token) => ({ url, ...(token ? { token } : {}) })` — a blank
* token OMITS the key (None = no credential), and 201 clears BOTH
* inputs (the credential is stored — write-only: the API shapes
* carry no token field, so nothing round-trips). The per-row editor
* (the ignore-paths dialog) mirrors it: `#ignore-editor-token` with
* the placeholder "leave blank to keep the current token" — it
* always opens BLANK (there is no token field to prefill from) and
* the PATCH body includes `token` ONLY when non-blank (the tri-state:
* absent = no change, the row's stored credential is kept). Every
* display site keeps rendering `s.url` UNCHANGED — the server now
* returns bare URLs (sanitize_url), so the list cell, its title
* attribute, the remove modal, and the editor's source line are
* token-free with no per-site change; the UI must never re-embed a
* credential.
*
* Phase 191 (task 03) — the per-source name / label / tags editor (the
* owner-visible half of "Sources need a name, a label, tag(s) …"): a
* SECOND list on the page, its own card below the sources table, whose
* ROW LIST is the phase-191 catalogue GET /api/source-labels (task 02) —
* the union of every registry row's DERIVED source name and every indexed
* document source — so every kind (git, local, upload) gets an editor
* without a second API (loadLabels → renderLabelRows → makeLabelRow, the
* page's ONE <template id="source-label-row-template"> cloned per row).
* The mono identity cell is the derived name, textContent-only and never
* an input: a label row is an ALIAS (D1/A1) — renaming here touches no
* document, citation, KB-tree node, saved chat or source/path locator.
* Each row expands (aria-expanded + aria-controls) into the three fields
* (Name ≤120 / Label ≤200 / the Tags token input — Enter or comma adds a
* chip, Backspace drops the last, ≤10 chips of ≤32 chars, the caps of the
* API's normalize_tags mirrored so an over-long submission never
* round-trips as a 422) + Save / Reset + the row's own §7.4 save-state
* line (role=status): dirty → "unsaved changes" + the .is-dirty row mark
* → "Saving…"/"Resetting…" → "saved" or the ONE-LINE error note, whose
* 404 arm names itself ("That source no longer exists") because a retry
* cannot help — the source left the catalogue. ONE in-flight request per
* ROW (the phase-168 saveInFlight pattern scoped per row — a second click
* while a request is out is ignored); the PUT / DELETE response is the
* new truth (no second GET), Reset clears the fields and the stored label,
* and a collapse discards the draft instead of leaving the row claiming
* something the server never got. The fetch sits inside the existing
* admin-gated content reveal (the page's gate is this list's gate) and
* every source-list change (add / remove / a finished upload) refreshes
* it, so a new source is nameable without a reload. The existing sync /
* upload / git-row flows are untouched — the editor is purely additive.
*
* Phase 135 (task 04) — the branch pin (the field, the tri-state
* asymmetry, the tag rule): the add form gains the optional
* `#git-source-branch` (maxlength 200, autocomplete=off, the visible
* "optional — default: the repo's default branch" hint) between the
* URL and Token fields; the submit body is
* `(url, token, branch) => ({ url, ...(branch ? { branch } : {}),
* ...(token ? { token } : {}) })` — a BLANK branch OMITS the key
* (NULL = the repo's default branch), and 201 clears the input
* alongside the URL/token. The per-row editor mirrors it:
* `#ignore-editor-branch` (after the source line, before the ignore
* textarea, with the "Leave empty for the repo's default branch." hint
* line) opens PREFILLED from the stored value (`s.branch ?? ""` —
* unlike the token, the API reports it, so there IS a field to read it
* from) and is VISIBLE FOR GIT ROWS ONLY — local rows hide the label +
* input + hint and the save body OMITS the key (local rows are walked,
* not cloned — the API's 422 is the backstop, the UI simply doesn't
* offer it). THE TRI-STATE ASYMMETRY: the git row's PATCH body ALWAYS
* includes `branch: <trimmed>` — an EMPTY save is the deliberate CLEAR
* leg (NULL = the repo's default branch), unlike the token's blank =
* keep (the token stays the blank = keep tri-state — untouched). Every
* STORED git row with a branch shows a `branch: <name>` text tag in the
* source cell (the "hidden on" tag idiom — text + background, never
* color alone: the text IS the signal); env-fallback rows (no `s.id`)
* and NULL-branch rows show no tag, and the source cell's title names
* the branch (the hover-precision idiom). The §7.4 lifecycle is the
* existing save handler's, unchanged: Save disables in flight, 200 →
* close the dialog + loadSources() (the row re-renders from the server
* — the tag appears/disappears), failure → the dialog's error line +
* the inputs keep the user's text.
*
* Scope boundary (phase locked decisions): adding a git repo does
* NOT clone — the sync service (server-side) does that. Removing a
* source, however, performs the FULL cleanup server-side (phase 69):
* the row, the source's indexed documents (chunks + embeddings), and
* — for git clones and uploaded archives — the app-managed files on
* disk (foreign local directories are never touched), all in one
* action; the confirmation modal states exactly that, and the
* page's hint box matches. The Sync button still mirrors the
* remaining sources (upstream file churn is pruned on that run).
* The phase-49 upload is the other in-place exception: it unpacks
* and registers the source in place (the phase-64 background task —
* 202 + status endpoint) and STOPS THERE — no model check, no
* import, no overview refresh (phase 90, A1): the scan is the RAG
* page's "Sync sources" button's job (it imports the uploaded
* kind=local row with prune + the row's ignore list), and the result
* line points at that button.
*
* The shared header module loads through this script's own relative
* import ("./header.js") — a hoisted import evaluated before this body
* runs (single-evaluation design: no direct <script> tag — the shell
* loads the view module ONLY through the router's lazy import();
* esbuild inlines it into the router bundle in the image build).
*/
import { fetchIsAdmin } from "./header.js";
const UPLOAD_TOAST_MS = 5000; // ~5 s auto-dismiss (A2)
let uploadToastEl = null; // the single toast node — lazy-created, reused
let uploadToastTimer = 0; // the pending auto-dismiss (replaced by a new toast)
export async function mount(root) {
/* ---------- page elements (git-sources.html, task 04) ---------- */
const gateEl = root.querySelector("#git-sources-gate");
const contentEl = root.querySelector("#git-sources-content");
const formEl = root.querySelector("#git-source-form");
const urlInput = root.querySelector("#git-source-url");
/* Phase 121: the add form's optional masked token field — blank =
no credential (the key is omitted from the POST body). */
const tokenInput = root.querySelector("#git-source-token");
/* Phase 135: the add form's optional branch pin — blank = the key
is omitted (NULL = the repo's default branch). */
const branchInput = root.querySelector("#git-source-branch");
const addBtn = root.querySelector("#git-source-add");
const addError = root.querySelector("#git-source-error");
/* Phase 49: the archive upload form (replaces the phase-38 local
directory form — same card, a file input instead of a path input).
The no-count result line (phase 90) renders in the role=status
result line. */
const uploadFormEl = root.querySelector("#archive-upload-form");
const uploadFileInput = root.querySelector("#archive-upload-file");
const uploadBtn = root.querySelector("#archive-upload-btn");
const uploadError = root.querySelector("#archive-upload-error");
const uploadResult = root.querySelector("#archive-upload-result");
const loadErrorEl = root.querySelector("#git-sources-load-error");
const loadErrorText = root.querySelector("#git-sources-load-error-text");
const retryBtn = root.querySelector("#git-sources-retry");
const tableWrap = root.querySelector("#git-sources-table-wrap");
const tbody = root.querySelector("#git-sources-tbody");
const emptyEl = root.querySelector("#git-sources-empty");
const envNote = root.querySelector("#git-sources-env-note");
const announcer = root.querySelector("#git-sources-announcer");
/* Phase 69: the remove confirmation modal (the native confirm()
retired) — static markup in git-sources.html; this module owns the
open / cancel / confirm lifecycle. */
const removeDialog = root.querySelector("#remove-confirm-dialog");
const removeBackdrop = root.querySelector(".remove-confirm-backdrop");
const removeSourceEl = root.querySelector("#remove-confirm-source");
const removeError = root.querySelector("#remove-confirm-error");
const removeCancelBtn = root.querySelector("#remove-confirm-cancel");
const removeRemoveBtn = root.querySelector("#remove-confirm-remove");
/* Phase 89: the per-source ignore-paths editor — the SAME page-local
alertdialog idiom as the remove-confirm modal (phase 69): static
markup in the shell's Sources view; this module owns the open /
cancel / save lifecycle. */
const ignoreDialog = root.querySelector("#ignore-editor-dialog");
const ignoreBackdrop = root.querySelector(".ignore-editor-backdrop");
const ignoreSourceEl = root.querySelector("#ignore-editor-source");
const ignoreTextarea = root.querySelector("#ignore-editor-textarea");
/* Phase 121: the editor's masked token field — blank = keep the
current token (the PATCH omits the key, the tri-state no-change). */
const ignoreTokenInput = root.querySelector("#ignore-editor-token");
/* Phase 135: the editor's branch field group (label + input + hint)
— git rows only (local rows hide all three); prefilled from the
stored value, and a git row's save ALWAYS sends the key (an empty
save is the tri-state's CLEAR leg — unlike the token's blank =
keep). */
const ignoreBranchLabel = root.querySelector('label[for="ignore-editor-branch"]');
const ignoreBranchInput = root.querySelector("#ignore-editor-branch");
const ignoreBranchHint = root.querySelector(".ignore-editor-branch-hint");
const ignoreErrorEl = root.querySelector("#ignore-editor-error");
const ignoreCancelBtn = root.querySelector("#ignore-editor-cancel");
const ignoreSaveBtn = root.querySelector("#ignore-editor-save");
/* Phase 105: the per-row "Hidden" toggle's page-level error line
(the checkbox is a table-cell control — no dialog of its own —
so its failure announces here, next to the table). */
const hiddenErrorEl = root.querySelector("#git-sources-hidden-error");
/* Phase 191 (task 03): the name/label/tags catalogue — this page's
SECOND list (its own endpoint, its own union of every source kind),
rendered into its own bounded table card. The template is the ONE
row skeleton cloned per source (makeLabelRow). */
const labelsErrorEl = root.querySelector("#source-labels-error");
const labelsWrap = root.querySelector("#source-labels-wrap");
const labelsTbody = root.querySelector("#source-labels-tbody");
const labelsEmptyEl = root.querySelector("#source-labels-empty");
const labelRowTemplate = root.querySelector("#source-label-row-template");
/* Polite live region: the screen-reader confirmation for loads, adds,
and removals (the phase-15 announcer pattern). */
function announce(message) {
if (announcer) announcer.textContent = message;
}
/* Added date — localized (toLocaleString); env-fallback rows carry
added_at null, and a corrupt timestamp must not blank the cell. */
function fmtDate(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString();
} catch {
return "—";
}
}
/* FastAPI error bodies: a string detail or the validation-error array
(the first entry's msg is the human line). Same extraction as
tuning.js — 422 shape-aware. */
async function apiDetail(r, fallback) {
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* ---------- load / render ---------- */
/* GET /api/git-sources → the table rows + env note + empty state.
The load error (role="alert" + retry) is the ONLY terminal state a
failed fetch may reach — never a stuck page. */
async function loadSources() {
let r;
try {
r = await fetch("/api/git-sources");
} catch {
showLoadError("Could not reach the server — is the app running?");
return;
}
if (!r.ok) {
showLoadError(await apiDetail(r, `The server could not list the git sources (${r.status}).`));
return;
}
let data;
try {
data = await r.json();
} catch {
showLoadError("The server sent an unreadable list — try again.");
return;
}
hideLoadError();
/* Phase 105: a healed list clears the stale hidden-toggle error
line (the phase-89 "happy path heals the error state" — the
same hideLoadError precedent). */
hideHiddenError();
const sources = Array.isArray(data.sources) ? data.sources : [];
renderSources(sources, data.from_env === true);
announce(`${sources.length} source${sources.length === 1 ? "" : "s"} listed.`);
}
function showLoadError(message) {
if (loadErrorText) loadErrorText.textContent = message;
if (loadErrorEl) loadErrorEl.hidden = false;
// The list state is unknown — hide the table AND the empty state so
// the error is the only claim about the list's contents.
if (tableWrap) tableWrap.hidden = true;
if (emptyEl) emptyEl.hidden = true;
}
function hideLoadError() {
if (loadErrorEl) loadErrorEl.hidden = true;
if (loadErrorText) loadErrorText.textContent = "";
}
function renderSources(sources, fromEnv) {
if (envNote) envNote.hidden = !fromEnv;
if (tbody) {
tbody.replaceChildren();
for (const s of sources) tbody.appendChild(makeRow(s));
}
const hasRows = sources.length > 0;
if (tableWrap) tableWrap.hidden = !hasRows;
if (emptyEl) emptyEl.hidden = hasRows;
}
/* One row: the kind badge (Git/Local — phase 38) followed by the
location in a mono <code> (textContent only — git URLs may contain
credentials, local paths may contain anything), the localized added
date ("—" for env fallback rows), and the per-row Remove button —
or the "from .env" tag for env-fallback rows (id null: nothing is
stored to remove; the env note says where the active list comes
from). The value is the git URL for kind "git" rows and the full
local path for kind "local" rows (the API reports the path in both
`path` and `url`; `path` is the kind-typed field). */
const REMOVE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
function makeRow(s) {
const tr = document.createElement("tr");
if (s.id) tr.dataset.id = s.id;
const isLocal = s.kind === "local";
// Phase 121: URLs arrive sanitized server-side — the UI must never re-embed a credential.
const value = isLocal ? (s.path ?? s.url) : s.url;
const kindLabel = isLocal ? "local" : "git";
const urlTd = document.createElement("td");
urlTd.className = "git-source-url-cell";
// Full URL/path on hover (long values scroll the wrapper) — phase
// 135: a pinned branch is named too (the tag below is the
// at-a-glance signal, the title the full context).
urlTd.title = s.id && s.branch ? `${value} — branch: ${s.branch}` : value;
const badge = document.createElement("span");
badge.className = `git-source-kind is-${isLocal ? "local" : "git"}`;
badge.textContent = isLocal ? "Local" : "Git"; // text + color, never color alone
const code = document.createElement("code");
code.textContent = value; // rendered as text, never as HTML
urlTd.append(badge, code);
/* Phase 89: the "N ignored" count tag — stored rows with a
non-empty list only (text + a distinct background, never color
alone — WCAG 1.4.1). The list round-trips: (s.ignore_paths ||
[]) is the same expression openIgnoreEditor prefills from. */
if (s.id && (s.ignore_paths || []).length > 0) {
const count = document.createElement("span");
count.className = "git-source-ignore-count";
count.textContent = `${s.ignore_paths.length} ignored`;
urlTd.append(count);
}
/* Phase 105 (A5): the "hidden on" state tag — the
.git-source-ignore-count idiom (TEXT + background, never
color alone — WCAG 1.4.1), so the flag is readable at a
glance without hovering the checkbox. */
if (s.id && s.include_hidden === true) {
const hiddenTag = document.createElement("span");
hiddenTag.className = "git-source-hidden-count";
hiddenTag.textContent = "hidden on";
urlTd.append(hiddenTag);
}
/* Phase 135 (A6): the "branch: <name>" state tag — STORED git rows
with a branch only (env-fallback rows carry no id, NULL-branch
rows no truthy branch — no tag); the .git-source-hidden-count
idiom (TEXT + background, never color alone — WCAG 1.4.1: the
text IS the signal). */
if (s.id && s.branch) {
const branchTag = document.createElement("span");
branchTag.className = "git-source-branch-count";
branchTag.textContent = `branch: ${s.branch}`;
urlTd.append(branchTag);
}
tr.appendChild(urlTd);
const addedTd = document.createElement("td");
addedTd.textContent = fmtDate(s.added_at);
tr.appendChild(addedTd);
const actTd = document.createElement("td");
actTd.className = "git-source-actions-cell";
if (s.id) {
/* Phase 105 (A5): the per-row hidden-folders toggle — a native
labeled checkbox (the WCAG focus/label idiom) LEFT of the
"Ignore paths" button; Remove stays last. Stored rows only
(A3 — env-fallback rows fall through to the "from .env"
tag). The aria-label is the ONLY place `value` appears
(setAttribute — never innerHTML). Checked state comes from
the SERVER row (s.include_hidden), never from a prior local
flip (§7.4 — makeRow only ever renders server state). */
const hiddenLabel = document.createElement("label");
hiddenLabel.className = "git-source-hidden";
hiddenLabel.title =
"When checked, files inside hidden (dot) folders are indexed on the next sync. Caches (.git, node_modules, .venv, …) stay excluded.";
const hiddenBox = document.createElement("input");
hiddenBox.type = "checkbox";
hiddenBox.className = "git-source-hidden-box";
hiddenBox.checked = s.include_hidden === true;
hiddenBox.setAttribute(
"aria-label",
`Index hidden folders for ${kindLabel} source: ${value}`,
);
/* Phase 156 (task 02, A3): the visible "Hidden" text is the
.cell-clip span — a squeezed cell shows "…" instead of a
hard cut; the full text stays reachable (the checkbox's
aria-label names the full source — the phase-105 idiom,
the label's own title carries the explanation). */
const hiddenClip = document.createElement("span");
hiddenClip.className = "cell-clip";
hiddenClip.textContent = "Hidden"; // never innerHTML (XSS contract)
hiddenLabel.append(hiddenBox, hiddenClip);
hiddenBox.addEventListener("change", () => toggleHidden(s, hiddenBox));
actTd.appendChild(hiddenLabel);
/* Phase 89: the per-row ignore-paths editor trigger (A3: stored
rows only — env-fallback rows fall through to the "from
.env" tag below). The aria-label is the ONLY place `value`
appears (setAttribute — never innerHTML); the text label is
static. Sits LEFT of Remove — the non-destructive action
first. */
const ignoreBtn = document.createElement("button");
ignoreBtn.type = "button";
ignoreBtn.className = "git-source-ignore";
ignoreBtn.setAttribute(
"aria-label",
`Edit ignored paths for ${kindLabel} source: ${value}`,
);
/* Phase 156 (task 02, A3): the static text label is the
.cell-clip span — ellipsizes in a squeezed cell (the
aria-label above keeps the full context reachable). */
const ignoreClip = document.createElement("span");
ignoreClip.className = "cell-clip";
ignoreClip.textContent = "Ignore paths"; // never innerHTML (XSS contract)
ignoreBtn.append(ignoreClip);
ignoreBtn.addEventListener("click", () => openIgnoreEditor(s, ignoreBtn));
actTd.appendChild(ignoreBtn);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "git-source-remove";
btn.setAttribute("aria-label", `Remove ${kindLabel} source: ${value}`);
/* Phase 156 (task 02, A3): the visible text is the .cell-clip
span (a static template — the icon is static SVG, the
established exception to the textContent house rule); a
squeezed cell ellipsizes it (the aria-label above names
the full source). */
btn.innerHTML = REMOVE_ICON + '<span class="cell-clip">Remove</span>';
// Phase 69: opens the confirmation modal (the native confirm()
// retired) — the modal names the source and states the
// full-removal policy; the per-row error span is retired (the
// modal carries the in-flight error line).
btn.addEventListener("click", () => openRemoveConfirm(s, btn));
actTd.appendChild(btn);
} else {
const tag = document.createElement("span");
tag.className = "git-source-env-tag";
tag.textContent = "from .env";
actTd.appendChild(tag);
}
tr.appendChild(actTd);
return tr;
}
/* ---------- remove (DELETE /api/git-sources/{id}) — the confirmation modal ----------
* A row's Remove button opens the page-local alertdialog
* (#remove-confirm-dialog — the native confirm() retired, phase 69)
* via openRemoveConfirm(s, triggerBtn): #remove-confirm-source shows the
* row's value (textContent ONLY — the same `value` expression
* makeRow uses: s.path ?? s.url for local rows, s.url for git — URLs
* may embed user:pass@ credentials, phase 32), the error line clears,
* and focus lands on Cancel (the safe default for a destructive
* action). Escape / Cancel / the dim backdrop close as CANCEL: no
* request, focus returns to the row's Remove button.
*
* "Remove source" (confirmRemove) runs the §7.4 never-stale
* lifecycle IN the modal: both buttons disable and the confirm
* relabels "Removing…" while the DELETE is out — the in-flight
* window covers the whole server-side cleanup (DB prune → file
* removal → best-effort overview refresh), so a slow LLM refresh is
* expected, not a stuck button. Navigating away mid-removal is not
* recommended: the row + index commit first, so the KB stays
* consistent; a rare interrupted file step leaves an inert orphan
* dir (no row → never imported again). 204 → close (focus return),
* loadSources(), then announce — the removal confirmation is the
* LAST announcement (the reload's "N sources listed." must not
* overwrite it — the announcer is the screen-reader confirmation for
* the destructive action); non-2xx → the in-modal role="alert" line
* (the server detail, apiDetail 422-shape-aware) + both buttons
* re-enabled + the confirm relabeled "Remove source" (the dialog
* stays open — the fix is one retry, not a re-search for the row);
* network failure → the fixed reachable? line, same restore. */
let removeTriggerBtn = null; // the row's Remove button — focus returns here on close
let removeInFlight = false; // §7.4: a DELETE is out (both buttons disabled)
let removingId = null; // the row id of the open/in-flight removal
function openRemoveConfirm(s, triggerBtn) {
if (!removeDialog || !removeSourceEl) return; // defensive — the markup ships with the page
if (removeInFlight) return; // one removal at a time
const isLocal = s.kind === "local";
// The same `value` expression makeRow uses — textContent ONLY.
removeSourceEl.textContent = isLocal ? s.path ?? s.url : s.url;
if (removeError) {
removeError.textContent = "";
removeError.hidden = true; // a new attempt starts clean
}
removingId = s.id;
removeInFlight = false;
removeTriggerBtn = triggerBtn; // recorded for the focus return on close
removeDialog.hidden = false;
document.addEventListener("keydown", onRemoveDialogKeydown);
// Cancel is the safe default for a destructive action — focus
// lands on it (visibly: the global 3px :focus-visible outline).
if (removeCancelBtn) removeCancelBtn.focus();
}
/* Any close (cancel, success): hide the dialog, clear the error
line, reset the buttons, detach the keydown handling, and return
focus to the row's Remove button (WCAG 2.1). */
function closeRemoveConfirm() {
if (!removeDialog) return;
removeDialog.hidden = true;
removeInFlight = false;
removingId = null;
if (removeError) {
removeError.textContent = "";
removeError.hidden = true;
}
if (removeCancelBtn) removeCancelBtn.disabled = false;
if (removeRemoveBtn) {
removeRemoveBtn.disabled = false;
removeRemoveBtn.textContent = "Remove source";
}
document.removeEventListener("keydown", onRemoveDialogKeydown);
const trigger = removeTriggerBtn;
removeTriggerBtn = null;
if (trigger) trigger.focus(); // focus returns to the row's Remove button
}
/* Escape / Cancel / backdrop all close as cancel — NO request. A
cancel is a no-op while a DELETE is in flight (no half-cancel of
an in-progress server-side removal; the buttons are disabled
anyway, the Escape/backdrop paths need this guard). */
function cancelRemoveConfirm() {
if (removeInFlight) return;
closeRemoveConfirm();
}
/* While open (attached in openRemoveConfirm, detached in
closeRemoveConfirm): Escape cancels; Tab/Shift+Tab cycle between
the modal's two buttons (the only focusable elements — aria-modal
is honored for keyboard users, not just screen readers). */
function onRemoveDialogKeydown(e) {
if (e.key === "Escape") {
e.preventDefault();
cancelRemoveConfirm();
return;
}
if (e.key === "Tab" && removeCancelBtn && removeRemoveBtn) {
const leaving = e.shiftKey ? removeCancelBtn : removeRemoveBtn;
const wrapTo = e.shiftKey ? removeRemoveBtn : removeCancelBtn;
if (document.activeElement === leaving) {
e.preventDefault();
wrapTo.focus();
}
}
}
async function confirmRemove() {
if (!removingId || removeInFlight) return; // one request at a time
removeInFlight = true;
if (removeError) {
removeError.textContent = "";
removeError.hidden = true;
}
if (removeCancelBtn) removeCancelBtn.disabled = true;
if (removeRemoveBtn) {
removeRemoveBtn.disabled = true;
removeRemoveBtn.textContent = "Removing…"; // §7.4 in-flight label
}
try {
const r = await fetch(`/api/git-sources/${encodeURIComponent(removingId)}`, {
method: "DELETE",
});
if (r.ok) {
// 204: the server confirmed the total removal (row + index +
// app-managed files).
closeRemoveConfirm(); // focus returns to the row's Remove button
await loadSources(); // the row leaves the table
await loadLabels(); // phase 191: and its label row leaves with it
// The removal confirmation is the LAST announcement: the
// reload's "N sources listed." must not overwrite it (the
// announcer is the screen-reader confirmation for the
// destructive action — phase 69 task 03's E2E pins the success
// line on the announcer after a real removal).
announce("Source removed — its files and index entries were cleaned up.");
return;
}
// non-2xx: the in-modal role="alert" line (the server detail) —
// the dialog STAYS open: the fix is one retry, not a re-search
// for the row.
if (removeError) {
removeError.textContent = await apiDetail(r, "Could not remove the source — try again.");
removeError.hidden = false;
}
} catch {
if (removeError) {
removeError.textContent = "Could not remove the source — is the app reachable?";
removeError.hidden = false;
}
} finally {
// Never stale (PLAN §7.4): the failure paths re-enable BOTH
// buttons + relabel the confirm; the success path already closed
// the dialog (which resets them) — the restore is a no-op there.
removeInFlight = false;
if (removeCancelBtn) removeCancelBtn.disabled = false;
if (removeRemoveBtn) {
removeRemoveBtn.disabled = false;
removeRemoveBtn.textContent = "Remove source";
}
}
}
/* The modal's own buttons (static markup — wired once). */
if (removeCancelBtn) removeCancelBtn.addEventListener("click", cancelRemoveConfirm);
if (removeBackdrop) removeBackdrop.addEventListener("click", cancelRemoveConfirm);
if (removeRemoveBtn) removeRemoveBtn.addEventListener("click", confirmRemove);
/* ---------- ignore paths (PATCH /api/git-sources/{id}) — the per-row editor ----------
* Phase 89: a stored row's "Ignore paths" button (makeRow — left of
* Remove) opens the page-local alertdialog
* (#ignore-editor-dialog — the EXACT #remove-confirm-dialog idiom,
* phase 69) via openIgnoreEditor(s, triggerBtn): #ignore-editor-
* source names the source (textContent ONLY — the same `value`
* expression makeRow uses: s.path ?? s.url for local rows, s.url
* for git — URLs may embed user:pass@ credentials, phase 32), the
* mono textarea PREFILLS the row's stored list (one path per line
* — the phase-89 A1 prefix rule, stated in plain words in the
* helper copy), the error line clears, and focus lands on Cancel
* (the safe default). Escape / Cancel / the dim backdrop close as
* CANCEL: no request, focus returns to the row's trigger button.
*
* "Save" (saveIgnorePaths) runs the §7.4 never-stale lifecycle:
* both buttons disable and the save relabels "Saving…" while the
* PATCH is out — a blank line in the box is a separator, not an
* entry (split + trim + drop empty lines client-side; the server
* still rejects empty entries defensively, A4). 200 → close
* (focus return) → loadSources (the "N ignored" count tag lands —
* the A5 replace round-trips through GET) → announce (the update
* confirmation is the LAST announcement — the reload's "N sources
* listed." must not overwrite it); non-2xx → the in-dialog
* role="alert" line (the server detail, apiDetail 422-shape-aware)
* + the buttons restored — the dialog STAYS open and the textarea
* content is KEPT (the instruction survives — the tuning-form
* convention); network failure → the fixed reachable? line, same
* restore. Env-fallback rows (id null) carry NO button (A3 —
* nothing is stored to edit). */
let ignoreTarget = null; // the row object of the open editor
let ignoreTriggerBtn = null; // the row's button — focus returns here on close
let ignoreInFlight = false; // §7.4: a PATCH is out (both buttons disabled)
function openIgnoreEditor(s, triggerBtn) {
if (!ignoreDialog || !ignoreTextarea) return; // defensive — the markup ships with the page
if (ignoreInFlight) return; // one editor at a time
const isLocal = s.kind === "local";
// The same `value` expression makeRow uses — textContent ONLY.
if (ignoreSourceEl) ignoreSourceEl.textContent = isLocal ? s.path ?? s.url : s.url;
ignoreTextarea.value = (s.ignore_paths || []).join("\n");
// Phase 121: the token field always opens BLANK — the API has no
// token field to prefill from (LOCKED A2); blank = the PATCH
// omits the key (no change — the stored token is kept).
if (ignoreTokenInput) ignoreTokenInput.value = "";
// Phase 135: the branch field — git rows open PREFILLED from the
// stored value (the API reports it on every git row — unlike the
// token, there IS a field to read it from); local rows HIDE the
// field group (label + input + hint — local rows are walked, not
// cloned; the API's 422 is the backstop, the UI simply doesn't
// offer it).
const branchIsGit = s.kind !== "local";
if (ignoreBranchLabel) ignoreBranchLabel.hidden = !branchIsGit;
if (ignoreBranchHint) ignoreBranchHint.hidden = !branchIsGit;
if (ignoreBranchInput) {
ignoreBranchInput.hidden = !branchIsGit;
ignoreBranchInput.value = branchIsGit ? (s.branch ?? "") : "";
}
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "";
ignoreErrorEl.hidden = true; // a new attempt starts clean
}
ignoreInFlight = false;
ignoreTarget = s;
ignoreTriggerBtn = triggerBtn; // recorded for the focus return on close
ignoreDialog.hidden = false;
document.addEventListener("keydown", onIgnoreDialogKeydown);
// Cancel is the safe default — focus lands on it (the remove-
// dialog precedent; visibly: the global 3px :focus-visible
// outline).
if (ignoreCancelBtn) ignoreCancelBtn.focus();
}
/* Any close (cancel, success): hide the dialog, reset the textarea
+ error line, clear the target, detach the keydown handling, and
return focus to the row's "Ignore paths" button (WCAG 2.1). */
function closeIgnoreEditor() {
if (!ignoreDialog) return;
ignoreDialog.hidden = true;
ignoreInFlight = false;
if (ignoreTextarea) ignoreTextarea.value = "";
if (ignoreTokenInput) ignoreTokenInput.value = ""; // phase 121: re-opens blank
if (ignoreBranchInput) ignoreBranchInput.value = ""; // phase 135: re-opens prefilled
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "";
ignoreErrorEl.hidden = true;
}
if (ignoreCancelBtn) ignoreCancelBtn.disabled = false;
if (ignoreSaveBtn) {
ignoreSaveBtn.disabled = false;
ignoreSaveBtn.textContent = "Save";
}
document.removeEventListener("keydown", onIgnoreDialogKeydown);
const trigger = ignoreTriggerBtn;
ignoreTarget = null;
ignoreTriggerBtn = null;
if (trigger) trigger.focus(); // focus returns to the row's button
}
/* Escape / Cancel / backdrop all close as cancel — NO request. A
cancel is a no-op while a PATCH is in flight (no half-cancel of
an in-progress save; the buttons are disabled anyway, the
Escape/backdrop paths need this guard). */
function cancelIgnoreEditor() {
if (ignoreInFlight) return;
closeIgnoreEditor();
}
/* While open (attached in openIgnoreEditor, detached in
closeIgnoreEditor): Escape closes as cancel. */
function onIgnoreDialogKeydown(e) {
if (e.key === "Escape") {
e.preventDefault();
cancelIgnoreEditor();
}
}
async function saveIgnorePaths() {
if (!ignoreTarget || ignoreInFlight) return; // one request at a time
// The box's lines: one path per line — a blank line is a
// separator, not an entry (trim + drop empty; the server still
// rejects empties defensively, A4).
const lines = ignoreTextarea.value.split("\n").map((l) => l.trim()).filter(Boolean);
// Phase 121: the masked token — BLANK = the key is omitted from
// the PATCH (the tri-state: absent = no change, the row's stored
// credential is kept); non-blank replaces it.
const token = ignoreTokenInput ? ignoreTokenInput.value.trim() : "";
const t = ignoreTarget;
// Phase 135: the branch pin — the tri-state ASYMMETRY (module
// docstring): a GIT row's PATCH body ALWAYS includes `branch`
// (the trimmed value — an EMPTY save is the deliberate CLEAR leg:
// NULL = the repo's default branch), unlike the token's blank =
// keep. A LOCAL row's body OMITS the key (the field group is
// hidden in the dialog — local rows are walked, not cloned; the
// API's 422 is the backstop).
const isGitRow = t.kind !== "local";
const branch = ignoreBranchInput ? ignoreBranchInput.value.trim() : "";
const value = t.kind === "local" ? (t.path ?? t.url) : t.url;
ignoreInFlight = true;
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "";
ignoreErrorEl.hidden = true;
}
if (ignoreCancelBtn) ignoreCancelBtn.disabled = true;
if (ignoreSaveBtn) {
ignoreSaveBtn.disabled = true;
ignoreSaveBtn.textContent = "Saving…"; // §7.4 in-flight label
}
try {
const r = await fetch(`/api/git-sources/${encodeURIComponent(t.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ignore_paths: lines,
...(isGitRow ? { branch } : {}), // phase 135: git rows always ("" = clear)
...(token ? { token } : {}),
}),
});
if (r.ok) {
// 200: the server replaced the row's list (A5).
closeIgnoreEditor(); // focus returns to the row's button
await loadSources(); // the "N ignored" count tag lands
// The update confirmation is the LAST announcement: the
// reload's "N sources listed." must not overwrite it (the
// same order as the remove flow).
announce(`Ignored paths updated for ${value}`);
return;
}
// non-2xx: the in-dialog role="alert" line (the server detail)
// — the dialog STAYS open and the textarea content is KEPT:
// the fix is one edit + retry, not a re-type.
if (ignoreErrorEl) {
ignoreErrorEl.textContent = await apiDetail(r, "Could not save the ignored paths — try again.");
ignoreErrorEl.hidden = false;
}
} catch {
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "Could not save the ignored paths — is the app reachable?";
ignoreErrorEl.hidden = false;
}
} finally {
// Never stale (PLAN §7.4): the failure paths re-enable BOTH
// buttons + relabel the save; the success path already closed
// the dialog (which resets them) — the restore is a no-op
// there.
ignoreInFlight = false;
if (ignoreCancelBtn) ignoreCancelBtn.disabled = false;
if (ignoreSaveBtn) {
ignoreSaveBtn.disabled = false;
ignoreSaveBtn.textContent = "Save";
}
}
}
/* ---------- hidden folders (PATCH /api/git-sources/{id}) — the per-row toggle ----------
* Phase 105: the hidden-folders toggle — PATCH { include_hidden }
* only (the row's list is untouched — the PATCH body's optional
* fields, task 03). §7.4 never-stale: the box disables at once
* (no double-flip while the PATCH is out); on 200 the row
* re-renders from the server (loadSources) and the CONFIRMATION is
* the LAST announcement (the reload's "N sources listed." lands
* first — the phase-89 order); on failure the box REVERTS to the
* server state and the detail lands in #git-sources-hidden-error
* (role=alert). The checkbox is a table-cell control (no dialog of
* its own — the ignore editor carries its error INSIDE the modal),
* so this toggle's error is the PAGE-level line; a healed
* loadSources clears it (hideHiddenError on the success path). */
function toggleHidden(s, box) {
const value = s.kind === "local" ? (s.path ?? s.url) : s.url;
const wanted = box.checked;
box.disabled = true; // a PATCH is out — the box must not flip twice
fetch(`/api/git-sources/${s.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ include_hidden: wanted }),
})
.then(async (r) => {
if (r.ok) {
hideHiddenError();
await loadSources();
announce(`Hidden folders ${wanted ? "enabled" : "disabled"} for ${value}.`);
return;
}
const detail = await apiDetail(
r, `Could not update the hidden-folders setting (${r.status}).`,
);
showHiddenError(detail);
box.checked = s.include_hidden === true; // revert to server state
box.disabled = false;
})
.catch(() => {
showHiddenError("Could not reach the server — the setting was not changed.");
box.checked = s.include_hidden === true;
box.disabled = false;
});
}
function showHiddenError(message) {
if (hiddenErrorEl) hiddenErrorEl.textContent = message;
if (hiddenErrorEl) hiddenErrorEl.hidden = false;
}
function hideHiddenError() {
if (hiddenErrorEl) hiddenErrorEl.textContent = "";
if (hiddenErrorEl) hiddenErrorEl.hidden = true;
}
/* The dialog's own buttons (static markup — wired once). */
if (ignoreCancelBtn) ignoreCancelBtn.addEventListener("click", cancelIgnoreEditor);
if (ignoreBackdrop) ignoreBackdrop.addEventListener("click", cancelIgnoreEditor);
if (ignoreSaveBtn) ignoreSaveBtn.addEventListener("click", saveIgnorePaths);
/* ---------- source names, labels and tags (/api/source-labels) — phase 191 task 03 ----------
* TODO.md L4: "Sources need a name, a label, tag(s) so you can focus the
* model when prompting if you have a project name or a standard name you
* reference things by." Tasks 01/02 stored and exposed that; this is the
* surface the owner types into.
*
* WHY A SECOND LIST (and not a column of #git-sources-table): the row list
* comes from GET /api/source-labels — the union of (a) every registry row's
* DERIVED source name and (b) every indexed document source — so a source
* is nameable even when it has no git_sources row of its own, and the page
* never has to guess the server's naming (the importer's derivation is the
* ONE owner, app/rag/source_removal.resolve_source_name). One extra fetch,
* inside the admin branch only (the anonymous path returns at the gate and
* this file never asks).
*
* WHY THE IDENTITY CELL IS READ-ONLY (D1/A1, owner-confirmed): the derived
* name is the wire identity of every document, citation, KB-tree node,
* saved chat, shared page and source/path locator. The name typed here is
* an ALIAS the prompt layers read (tasks 04/05) — never a rename — so
* nothing in this block writes, resolves or joins on it: it is looked up
* (encodeURIComponent in the path) exactly as the API's PUT/DELETE take it.
*
* THE SAVE CONTRACT (§7.4 never-stale, the phase-168 shape scoped PER ROW):
* an edit marks the row (.is-dirty) + "unsaved changes" + enables that row's
* Save; Save/Reset set the row's own `saveInFlight` (a second click while a
* request is out is IGNORED, and the in-flight label owns the status line),
* the row settles on "saved" or the one-line error note — never a stuck
* "Saving…" on ANY outcome (the finally always drops the flag). The PUT /
* DELETE RESPONSE is the new truth (no second GET — task 03): the stored
* label, the fields, the state tag and the summary all re-paint from the
* body the server just wrote, so the row can never show something the
* server does not have. Collapsing the editor DISCARDS the draft (no
* request) and refills from the stored label for the same reason. */
/* The caps of app/rag/source_labels (D1/D5), mirrored client-side so a
long tag never costs a round trip: the input's maxlength enforces the
32/120/200 char caps in the markup, this pair owns the chip count. */
const LABELS_ENDPOINT = "/api/source-labels";
const LABEL_TAG_MAX_CHARS = 32;
const LABEL_TAGS_MAX_COUNT = 10;
/* The save-state line's copy (role=status, one line per row). */
const LABEL_STATUS_SAVED = "saved";
const LABEL_STATUS_DIRTY = "unsaved changes";
const LABEL_STATUS_SAVING = "Saving…";
const LABEL_STATUS_RESETTING = "Resetting…";
/* The 404 arm NAMED (task 03): the source is gone from the catalogue
(removed elsewhere, or its last document pruned), so a retry cannot
help — the owner needs a reload, not a retype. */
const LABEL_NOTE_GONE = "That source no longer exists — reload the page.";
const LABEL_FAIL_SAVE = "Could not save the name — try again.";
const LABEL_FAIL_RESET = "Could not reset the name — try again.";
/* Per-clone id sequence: the template ships ONE id per field, and a
literal clone N times over would duplicate it (WCAG 1.3.1 — every
<label for> would aim at the first match). makeLabelRow rewrites the
input ids, the matching <label for>s and the editor id (the toggle's
aria-controls target) with this counter. */
let labelRowSeq = 0;
function storedFromEntry(entry) {
/* One label row in its SERVER shape → the row's stored state. Nulls /
missing keys fold to "" / [] (the API's "no row" shape is identical
to a cleared label — D1), so a row with nothing stored renders the
empty form and no Reset. */
const e = entry || {};
return {
display_name: typeof e.display_name === "string" ? e.display_name : "",
label: typeof e.label === "string" ? e.label : "",
tags: Array.isArray(e.tags) ? e.tags.map((tag) => String(tag)) : [],
};
}
function hasStoredLabel(stored) {
return Boolean(stored.display_name || stored.label || stored.tags.length);
}
/* The collapsed row's read-out of the stored label — name · label · the
tag list. "" renders nothing (the row shows the toggle instead). */
function labelSummaryText(stored) {
const parts = [];
if (stored.display_name) parts.push(stored.display_name);
if (stored.label) parts.push(stored.label);
if (stored.tags.length) parts.push(stored.tags.join(", "));
return parts.join(" · ");
}
/* The token input's chip list, re-painted from the row's draft array.
The chips are the phase-89 tag idiom (.source-label-tag joins the
.git-source-ignore-count / -hidden-count / -branch-count rule — TEXT +
background, never color alone), so NO new tag-input CSS is needed
beyond the list's own layout. At the cap the input disables (the tenth
chip is the last one the API accepts). */
function paintLabelTags(view) {
view.tagsList.replaceChildren(
...view.tags.map((tag) => {
const li = document.createElement("li");
li.className = "source-label-tag";
li.textContent = tag; // textContent only (the XSS contract)
return li;
}),
);
const full = view.tags.length >= LABEL_TAGS_MAX_COUNT;
view.tagsInput.disabled = full;
view.tagsInput.placeholder = full
? `${LABEL_TAGS_MAX_COUNT} tags at most`
: "gitea";
}
/* Type + Enter (or a comma) adds a chip. The rules are the API's
normalize_tags, applied client-side: trim, drop the empties, keep the
FIRST case of a duplicate, cap the count — a duplicate chip would
otherwise show the owner two chips the server de-duplicates to one. */
function addLabelTags(view, raw) {
for (const piece of String(raw).split(",")) {
const tag = piece.trim().slice(0, LABEL_TAG_MAX_CHARS);
if (!tag) continue;
if (view.tags.some((t) => t.toLowerCase() === tag.toLowerCase())) continue;
if (view.tags.length >= LABEL_TAGS_MAX_COUNT) break;
view.tags.push(tag);
}
view.tagsInput.value = "";
paintLabelTags(view);
updateLabelRowUI(view);
}
function readLabelForm(view) {
return {
display_name: view.nameInput.value.trim(),
label: view.labelInput.value.trim(),
tags: view.tags.slice(),
};
}
function fillLabelForm(view) {
view.nameInput.value = view.stored.display_name;
view.labelInput.value = view.stored.label;
view.tagsInput.value = "";
view.tags = view.stored.tags.slice();
paintLabelTags(view);
}
function labelFormDirty(view) {
const form = readLabelForm(view);
return (
form.display_name !== view.stored.display_name ||
form.label !== view.stored.label ||
form.tags.join("\n") !== view.stored.tags.join("\n")
);
}
function setLabelStatus(view, text, kind) {
view.statusEl.textContent = text;
view.statusEl.classList.toggle("is-error", kind === "error");
view.statusEl.classList.toggle("is-hint", kind === "hint");
}
/* The row's stored-state paint: the "named" tag, the summary line, the
Reset button (a row with nothing stored has nothing to reset — the
phase-168 per-row reset shape) and the toggle's label + aria-label
(the ONLY place the source name rides on the controls — setAttribute,
never innerHTML, the phase-89/105 idiom). */
function paintLabelRowState(view) {
const labelled = hasStoredLabel(view.stored);
if (view.stateTag) view.stateTag.hidden = !labelled;
if (view.summaryEl) {
const text = labelSummaryText(view.stored);
view.summaryEl.textContent = text;
view.summaryEl.hidden = text === "";
}
view.resetBtn.hidden = !labelled;
view.toggleBtn.textContent = labelled ? "Edit name" : "Name this source";
view.toggleBtn.setAttribute(
"aria-label",
`${labelled ? "Edit" : "Set"} the name, label and tags for source: ${view.source}`,
);
view.saveBtn.setAttribute(
"aria-label",
`Save the name, label and tags for source: ${view.source}`,
);
view.resetBtn.setAttribute(
"aria-label",
`Reset the name, label and tags of source: ${view.source}`,
);
}
/* Drop a failure note the moment the draft moves again: the note
explains the submission the server refused, and that submission is
gone as soon as the owner types. Called by the edit handlers ONLY —
never by updateLabelRowUI, which is exactly what a finishing request
calls (and would erase the note it just wrote). */
function clearLabelError(view) {
if (view.statusEl.classList.contains("is-error")) setLabelStatus(view, "", null);
}
/* The per-row dirty paint (the phase-168 .is-dirty shape, scoped to ONE
row instead of the panel). While a request is out the in-flight label
owns the status line — a keystroke mid-save must not overwrite it. */
function updateLabelRowUI(view) {
const dirty = labelFormDirty(view);
view.tr.classList.toggle("is-dirty", dirty);
if (view.saveInFlight) return;
view.saveBtn.disabled = !dirty;
/* A FAILED save's note survives this paint (§7.4): the dirty hint and
the note are the same one line, and the hint is the weaker claim —
silently replacing "That source no longer exists — reload the
page." with "unsaved changes" hides the one error the owner cannot
fix by retrying. The note clears on the next edit (clearLabelError)
or on a collapse, never by re-deriving the dirty state. */
if (view.statusEl.classList.contains("is-error")) return;
if (dirty) setLabelStatus(view, LABEL_STATUS_DIRTY, "hint");
else if (view.statusEl.classList.contains("is-hint")) {
setLabelStatus(view, "", null);
}
}
/* Expand / collapse. A collapse discards the draft (no request) and
refills from the stored label, so a closed row never claims an edit the
server never got; a save in flight freezes the row. */
function setLabelEditorOpen(view, open) {
if (view.saveInFlight) return;
view.editor.hidden = !open;
view.toggleBtn.setAttribute("aria-expanded", open ? "true" : "false");
if (!open) {
fillLabelForm(view);
setLabelStatus(view, "", null);
}
updateLabelRowUI(view);
}
/* The one-line error note. The 404 arm names itself (a retry cannot fix a
source that left the catalogue); every other non-2xx carries the server
detail (apiDetail, 422 shape-aware — the cap that was crossed names
itself there). */
async function labelFailureNote(r, fallback) {
if (r.status === 404) return LABEL_NOTE_GONE;
return await apiDetail(r, fallback);
}
/* PUT the whole form (the three keys always ride — an empty Name is the
deliberate CLEAR leg, the API folds "" to NULL; the tags array is the
chip list). 200 → the response body IS the row's new stored label. */
async function saveLabelRow(view) {
if (view.saveInFlight) return; // §7.4 — one request per row at a time
const form = readLabelForm(view);
view.saveInFlight = true;
view.saveBtn.disabled = true;
view.resetBtn.disabled = true;
setLabelStatus(view, LABEL_STATUS_SAVING, null);
try {
const r = await fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (r.ok) {
/* The response is the new truth — NO second GET (task 03). A body
that cannot be parsed degrades to what was just sent (never to
"unknown": the write succeeded either way). */
let saved = null;
try {
saved = await r.json();
} catch {
saved = null;
}
view.stored = storedFromEntry(
saved || {
display_name: form.display_name || null,
label: form.label || null,
tags: form.tags,
},
);
fillLabelForm(view);
paintLabelRowState(view);
hideLabelsError(); // a healed save heals the catalogue line too
setLabelStatus(view, LABEL_STATUS_SAVED, null);
return;
}
setLabelStatus(view, await labelFailureNote(r, LABEL_FAIL_SAVE), "error");
} catch {
setLabelStatus(view, `${LABEL_FAIL_SAVE} Is the app reachable?`, "error");
} finally {
// Never stale on ANY outcome (§7.4): the flag drops, the buttons
// come back, and the dirty paint re-decides Save.
view.saveInFlight = false;
view.resetBtn.disabled = false;
updateLabelRowUI(view);
}
}
/* DELETE the row — the fields clear, the stored label nulls, and the
row settles on the same "saved" line (the row now matches the server,
which is all the line has ever claimed). */
async function resetLabelRow(view) {
if (view.saveInFlight) return; // the SAME per-row guard covers Reset
view.saveInFlight = true;
view.saveBtn.disabled = true;
view.resetBtn.disabled = true;
setLabelStatus(view, LABEL_STATUS_RESETTING, null);
try {
const r = await fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`, {
method: "DELETE",
});
if (r.ok) {
// The 200 body is the entry's cleared state (task 02) — the row
// re-reads it instead of assuming, and the fields clear with it.
let cleared = null;
try {
cleared = await r.json();
} catch {
cleared = null;
}
view.stored = storedFromEntry(cleared || {});
fillLabelForm(view);
paintLabelRowState(view);
hideLabelsError();
setLabelStatus(view, LABEL_STATUS_SAVED, null);
return;
}
setLabelStatus(view, await labelFailureNote(r, LABEL_FAIL_RESET), "error");
} catch {
setLabelStatus(view, `${LABEL_FAIL_RESET} Is the app reachable?`, "error");
} finally {
view.saveInFlight = false;
view.resetBtn.disabled = false;
updateLabelRowUI(view);
}
}
/* Clone the page's ONE row template for one catalogue entry. The
template is static markup (no data): every value lands through
textContent / .value, and the row's own object (`view`) carries its
state — stored label, chip draft, saveInFlight — closed over by its
listeners. */
function makeLabelRow(entry) {
const proto =
labelRowTemplate && labelRowTemplate.content
? labelRowTemplate.content.querySelector("tr")
: null;
if (!proto) return null; // defensive — the template ships with the page
const tr = proto.cloneNode(true);
labelRowSeq += 1;
const seq = labelRowSeq;
const pick = (sel) => tr.querySelector(sel);
const nameInput = pick(".source-label-name-input");
const labelInput = pick(".source-label-label-input");
const tagsInput = pick(".source-label-tags-input");
const tagsList = pick(".source-label-tags");
const editor = pick(".source-label-editor");
const toggleBtn = pick(".source-label-toggle");
const saveBtn = pick(".source-label-save");
const resetBtn = pick(".source-label-reset");
const statusEl = pick(".source-label-status");
if (!nameInput || !labelInput || !tagsInput || !tagsList || !editor ||
!toggleBtn || !saveBtn || !resetBtn || !statusEl) {
return null; // a template without the fields is a build error, not a row
}
/* Rewrite the template's static ids (and the <label for>s that point at
them) so every row keeps a unique id and a real label pair; the
editor id is the toggle's aria-controls target. */
for (const [input, base] of [
[nameInput, "source-label-name"],
[labelInput, "source-label-label"],
[tagsInput, "source-label-tags"],
]) {
input.id = `${base}-${seq}`;
const lbl = tr.querySelector(`label[for="${base}"]`);
if (lbl) lbl.setAttribute("for", input.id);
}
editor.id = `source-label-editor-${seq}`;
toggleBtn.setAttribute("aria-controls", editor.id);
const codeEl = pick(".source-label-source");
const source = String((entry && entry.source) || "");
if (codeEl) codeEl.textContent = source; // the identity: read-only text
const cell = pick(".source-label-id-cell");
if (cell) cell.title = source; // long names hover in full (house idiom)
const view = {
source,
tr,
stored: storedFromEntry(entry),
tags: [],
saveInFlight: false,
nameInput,
labelInput,
tagsInput,
tagsList,
editor,
toggleBtn,
saveBtn,
resetBtn,
statusEl,
stateTag: pick(".source-label-state"),
summaryEl: pick(".source-label-summary"),
};
fillLabelForm(view);
paintLabelRowState(view);
updateLabelRowUI(view);
for (const input of [nameInput, labelInput]) {
input.addEventListener("input", () => {
clearLabelError(view);
updateLabelRowUI(view);
});
}
tagsInput.addEventListener("keydown", (e) => {
clearLabelError(view);
// Enter or a comma commits the token; the comma never "types".
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addLabelTags(view, tagsInput.value);
return;
}
// Backspace on an empty input drops the LAST chip (the token rule).
if (e.key === "Backspace" && tagsInput.value === "" && view.tags.length) {
e.preventDefault();
view.tags.pop();
paintLabelTags(view);
updateLabelRowUI(view);
}
});
// Leaving the field commits a half-typed token (nothing is lost).
tagsInput.addEventListener("blur", () => {
if (tagsInput.value.trim()) addLabelTags(view, tagsInput.value);
});
toggleBtn.addEventListener("click", () => setLabelEditorOpen(view, editor.hidden));
saveBtn.addEventListener("click", () => saveLabelRow(view));
resetBtn.addEventListener("click", () => resetLabelRow(view));
return tr;
}
/* GET the label catalogue (ONE fetch — the union the server assembles).
A failure is this list's own role=alert line: the sources table above
keeps working (the two lists are independent), and the table + empty
state hide while the error is up so the note is the only claim about
the rows (the #git-sources-load-error discipline). */
async function loadLabels() {
let r;
try {
r = await fetch(LABELS_ENDPOINT);
} catch {
showLabelsError("Could not reach the server — the source names are not shown.");
return;
}
if (!r.ok) {
showLabelsError(
await apiDetail(r, `The server could not list the source names (${r.status}).`),
);
return;
}
let data;
try {
data = await r.json();
} catch {
showLabelsError("The server sent an unreadable label list — try again.");
return;
}
hideLabelsError();
renderLabelRows(Array.isArray(data.labels) ? data.labels : []);
}
function renderLabelRows(entries) {
if (!labelsTbody) return;
labelsTbody.replaceChildren(); // the list is REPLACED, never appended
for (const entry of entries) {
const tr = makeLabelRow(entry);
if (tr) labelsTbody.appendChild(tr);
}
const hasRows = labelsTbody.children.length > 0;
if (labelsWrap) labelsWrap.hidden = !hasRows;
if (labelsEmptyEl) labelsEmptyEl.hidden = hasRows;
}
function showLabelsError(message) {
if (labelsErrorEl) {
labelsErrorEl.textContent = message;
labelsErrorEl.hidden = false;
}
// The list state is unknown — hide the table AND the empty state.
if (labelsWrap) labelsWrap.hidden = true;
if (labelsEmptyEl) labelsEmptyEl.hidden = true;
}
function hideLabelsError() {
if (labelsErrorEl) {
labelsErrorEl.textContent = "";
labelsErrorEl.hidden = true;
}
}
/* ---------- add (POST /api/git-sources) — the git form ----------
* wireAddForm gives the form the §7.4 never-stale lifecycle: while
* the request is out the button disables + relabels "Adding…" and
* re-enables (idle label restored) on success AND failure. 201 clears
* the input, reloads the list, and focuses the new row's Remove button
* (a11y); a failure (409 duplicate, 422 validation) shows the server
* detail inline under the form (role="alert", 422 shape-aware via
* apiDetail) and keeps the input — the fix is one edit, not a re-type.
* 409/422 details are fixed generic strings (credential safety — the
* URL is never echoed). */
function wireAddForm(opts) {
const { form, input, btn, error, tokenInput, branchInput } = opts;
if (!form || !input || !btn) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
// Client-side non-empty check (the input is `required` too — the
// browser's native prompt is the first line, this one the second).
const value = input.value.trim();
if (!value) {
if (error) {
error.textContent = opts.emptyMessage;
error.hidden = false;
}
return;
}
if (error) error.hidden = true; // a new attempt starts clean
btn.disabled = true; // §7.4: one POST per click
btn.textContent = "Adding…";
try {
// Phase 121: the masked token rides the same POST — blank =
// the key is omitted (None = no credential; LOCKED A2).
const token = tokenInput ? tokenInput.value.trim() : "";
// Phase 135: the branch pin rides the same POST — blank = the
// key is omitted (NULL = the repo's default branch).
const branch = branchInput ? branchInput.value.trim() : "";
const r = await fetch("/api/git-sources", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts.body(value, token, branch)),
});
if (r.ok) {
let createdId = null;
try {
createdId = (await r.json()).id ?? null;
} catch {
/* the 201 body is advisory — the reload is the truth */
}
input.value = ""; // 201: the source is stored
if (tokenInput) tokenInput.value = ""; // the credential is stored (write-only)
if (branchInput) branchInput.value = ""; // the branch is stored (NULL when blank)
announce(opts.addedMessage);
await loadSources(); // the new row lands in the table
await loadLabels(); // phase 191: and it is nameable straight away
focusNewRow(createdId); // a11y: land the caret on the new row
return;
}
// 409 duplicate / 422 validation / anything else: the server
// detail inline, form kept — the input survives so the fix is
// one edit, not a re-type.
if (error) {
error.textContent = await apiDetail(r, opts.failMessage);
error.hidden = false;
}
} catch {
if (error) {
error.textContent = opts.networkMessage;
error.hidden = false;
}
} finally {
btn.disabled = false; // never stale — success OR failure
btn.textContent = opts.idleLabel;
}
});
}
wireAddForm({
form: formEl,
input: urlInput,
tokenInput,
branchInput,
btn: addBtn,
error: addError,
// Phase 135: the branch pin is included ONLY when non-blank
// (blank = key omitted = the repo's default branch, stored NULL).
// Phase 121: the masked token is included ONLY when non-blank
// (blank = key omitted = no credential — the API's tri-state).
body: (url, token, branch) => ({ url, ...(branch ? { branch } : {}), ...(token ? { token } : {}) }),
emptyMessage: "Enter a git URL to add.",
failMessage: "Could not add the git source — try again.",
networkMessage: "Could not add the git source — is the app reachable?",
addedMessage: "Git source added.",
idleLabel: "Add source",
});
/* ---------- upload (POST /api/git-sources/upload) — phase 64 (task 05), unpack-only (phase 90) -------
* The archive upload form follows the phase-64 202 contract (A1):
* the file input's selection is posted as FormData (the browser sets
* the multipart boundary — no manual Content-Type), and the 202
* answers the moment the archive is safely on disk — the "Uploading…"
* label covers only that short receive. Then the button HANDS OVER to
* the background run (phase 90: UNPACK + REGISTER only — no scan):
* 202 → the page-local "Successfully uploaded — <file>" toast
* (showUploadToast — A2, safe to navigate away), the file input
* clears, and the processing state (bare "Processing…", disabled,
* title cleared) + startUploadPolling() own it — a 2 s poll of
* GET /api/git-sources/upload/status that renders the bare
* "Processing…" label for the WHOLE run (phase 90, A2 — the unpack
* has no file-level progress: no file, no "(n/m)" counts, no title)
* and settles it: success → the ready-for-sync line ("Uploaded
* <name> (<stamp>) — press Sync sources to import it." — the
* phase-154 completion stamp, fmtUploadStamp off the status's
* finished_at; the A1 date-qualified form is cited in
* fmtUploadResult's comment, not restated — fmtUploadResult off
* the status's {"message": "uploaded"} detail) in the role=status
* result line + the "Archive uploaded — press Sync sources to
* import it." announce + loadSources (NO second toast — it already
* fired at the 202, A2); failure → the sanitized server error in
* the role=alert banner + loadSources, the file selection KEPT for
* a one-click re-upload. 409 (an upload is already in progress)
* raises NO error banner — it re-attaches to the in-flight run
* (processing state + poll, never stale); the phase-49 "server
* detail inline for 409" branch is superseded. Other non-2xx (422
* format/name, 413 cap, 5xx) keep the phase-49 error banner + the
* kept file selection; a network failure keeps the fixed line. The
* submit finally restores the button ONLY when no poll is active
* (PLAN §7.4 — while startUploadPolling owns the button it stays
* disabled / "Processing…"). Boot re-attach (initUploadStatus, the
* admin branch): a running run re-enters the processing state + poll
* (no second upload, no error); a terminal run re-renders its result
* line (success) or error banner (failed); idle does nothing.
* (The phase-49 synchronous 200 paragraph is superseded by phase 64;
* the phase-64 scan counts are superseded by phase 90.) */
/* The result line's text (phase 90, A2 — the no-count contract,
extended by phase 154 task 02 with the completion stamp): the
status success detail is exactly {"message": "uploaded"} — the
sync-style counts the phase-64 line rendered are gone (the scan —
and its counts — belong to the Sync button, which renders them on
the RAG page). `name` is the accepted 202's safe source name
(lastUploadName) when the run started on this page; it is null
after a reload or on the 409 re-attach (the line still points at
the next step, only without the name). `when` is the PRECOMPUTED
completion stamp (fmtUploadStamp(status) — the phase-154 A1
date-qualified form, the rule cited in sources.js's fmtSyncTime,
not restated); "" (a running / legacy / partial payload without
timestamps) renders the byte-identical pre-phase-154 strings. */
function fmtUploadResult(detail, name, when) {
if (detail && detail.message === "uploaded") {
if (name) {
return when
? `Uploaded ${name} (${when}) — press Sync sources to import it.`
: `Uploaded ${name} — press Sync sources to import it.`;
}
return when
? `Uploaded ${when} — press Sync sources to import it.`
: "Uploaded — press Sync sources to import it.";
}
return "The upload finished.";
}
/* Phase 154 (task 02): the upload line's completion stamp —
* PRECOMPUTED from the /upload/status payload (the per-page
* duplication idiom: the SAME date-qualifier as sources.js's
* fmtSyncTime, duplicated, not shared — the A1 rule — same local
* day `HH:MM`, same year `M/D, h:MM AM/PM`, across years
* `M/D/YYYY, h:MM AM/PM` — is cited there, not restated). Reads
* status.finished_at first (the success state always has it on the
* server), falls back to status.started_at (the defensive guard
* for a legacy / partial payload — a corrupt finished_at degrades
* to the started_at stamp rather than to no stamp), and yields
* "" when both are missing/absent or every ISO is unparseable
* (the pre-phase-154 byte-identical fallback — the line renders
* the pre-stamp copy). */
function fmtUploadStamp(status) {
const s = status || {};
let d = null;
for (const iso of [s.finished_at, s.started_at]) {
if (!iso) continue;
const t = new Date(iso);
if (!Number.isNaN(t.getTime())) {
d = t;
break;
}
}
if (d === null) 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}`;
}
/* Upload-success toast (phase 64 task 05, A2 — owner-locked): the
* "successfully uploaded" confirmation, the phase-55 share-toast
* pattern (frontend/assets/app.js) made page-local. A SINGLE node —
* lazy-created on the first 202 and reused (toasts never stack): a
* new toast replaces a pending one (clear the prior dismiss timer,
* re-run the entry). role="status" aria-live="polite" — on THIS page
* the toast IS the a11y announcer for the 202 (there is no other
* live-region line for it). SUCCESS-ONLY (A2): failures are the
* #archive-upload-error banner, never a toast. The .toast CSS ships
* as-is (styles.css, phase 55). */
function showUploadToast(message) {
if (!uploadToastEl) {
uploadToastEl = document.createElement("div");
uploadToastEl.className = "toast";
uploadToastEl.setAttribute("role", "status");
uploadToastEl.setAttribute("aria-live", "polite");
document.body.appendChild(uploadToastEl);
}
uploadToastEl.textContent = message; // XSS-safe text assignment
// Re-trigger the entry even when a toast is already up (a second
// upload accepted while the first toast is showing): clear the
// pending dismiss, drop the visible state, force a reflow
// (restarts the CSS transition), then show again.
clearTimeout(uploadToastTimer);
uploadToastEl.classList.remove("is-visible");
void uploadToastEl.offsetWidth; // force reflow — the entry transition restarts
uploadToastEl.classList.add("is-visible");
uploadToastTimer = setTimeout(() => {
uploadToastEl.classList.remove("is-visible"); // auto-dismiss ~5 s
}, UPLOAD_TOAST_MS);
}
/* The background-run poll (phase 64 task 05, unpack-only in phase
* 90): a 2 s cadence — the SYNC_POLL_MS house value. Single timer,
* one loop at a time (the guard makes a double-start a no-op, and
* the submit finally reads this same variable to know whether the
* poll OWNS the button). Each tick fetches
* GET /api/git-sources/upload/status: running → the bare
* "Processing…" label for the whole run (phase 90, A2 — the unpack
* has no file-level progress: no file, no "(n/m)" counts, the title
* stays clear) + reschedule; success → stop + the ready-for-sync
* result line + the announcement + the row reload (NO toast — it
* fired at the 202, A2); failed → stop + the sanitized server error
* banner + the row reload (a post-swap failure keeps the row — the
* list state may have changed), the file selection kept for a
* one-click re-upload; idle → stop + the button restored
* (defensive — a started run never returns to idle). A network blip
* retries next tick. */
const UPLOAD_POLL_MS = 2000; // the SYNC_POLL_MS house value
let uploadPollTimer = null; // null = no poll active (the finally's guard)
let lastUploadName = null; // phase 90: the accepted 202's safe source name — the result line's <name> (null after a reload / on the 409 re-attach)
function stopUploadPolling() {
if (uploadPollTimer !== null) {
clearTimeout(uploadPollTimer);
uploadPollTimer = null;
}
}
/* The button's processing entry (the 202 + the 409 re-attach): from
* here the poll OWNS it — disabled, bare "Processing…", title
* cleared (a live file never lands on it — phase 90: the run is
* unpack + register only, so the label stays bare). */
function enterUploadProcessingState() {
uploadBtn.disabled = true;
uploadBtn.textContent = "Processing…";
uploadBtn.title = "";
}
/* The idle restore (the poll's terminal branches + the submit
* finally, which calls this ONLY when no poll is active — PLAN §7.4). */
function restoreUploadButton() {
uploadBtn.disabled = false; // never stale — success OR failure
uploadBtn.textContent = "Upload";
uploadBtn.removeAttribute("title");
}
function startUploadPolling() {
if (uploadPollTimer !== null) return; // one poll loop at a time
const tick = async () => {
let status = null;
try {
const r = await fetch("/api/git-sources/upload/status");
if (r.ok) status = await r.json();
} catch { /* network blip — retry next tick */ }
if (!status) {
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
return;
}
// running: the bare label for the whole background run (phase
// 90, A2 — the unpack has no file-level progress, so no file,
// no counts, and the title stays clear).
if (status.state === "running") {
uploadBtn.textContent = "Processing…";
uploadBtn.title = "";
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
return;
}
stopUploadPolling();
if (status.state === "success") {
// The run finished (unpack + register only — phase 90): the
// ready-for-sync line (fmtUploadResult reads the no-count
// detail + fmtUploadStamp's completion stamp — phase 154),
// the announcement, the row lands. NO toast here — it
// already fired at the 202 (A2).
const detail = status.detail || {};
if (uploadResult) {
uploadResult.textContent = fmtUploadResult(
detail,
lastUploadName,
fmtUploadStamp(status)
);
uploadResult.hidden = false;
}
announce("Archive uploaded — press Sync sources to import it.");
uploadFileInput.value = "";
restoreUploadButton();
loadSources(); // the row lands / refreshes
loadLabels(); // phase 191: the uploaded source joins the name list
return;
}
if (status.state === "failed") {
// Sanitized server-side (task 03's _sanitize_error): the banner
// is the failure UI (A2), the selection stays KEPT for a
// one-click re-upload, and the list reloads (a post-swap failure
// keeps the row — the list state may have changed).
if (uploadError) {
uploadError.textContent = status.error || "The upload failed.";
uploadError.hidden = false;
}
restoreUploadButton();
loadSources(); // the list state may have changed
return;
}
// idle: defensive — a started run never returns to idle; just
// settle the button (the poll stopped above).
restoreUploadButton();
};
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
}
/* Boot re-attach (phase 64 task 05, the admin branch only): fetch the
* upload status ONCE — a running run re-enters the processing state
* + the poll (a reload mid-run re-attaches instead of dead-ending —
* no second upload, no error); a finished run re-renders its result
* line ONLY (no announce, no toast — the toast fired at the 202, A2;
* the name is unknown after a reload — lastUploadName is null — so
* the line is the nameless "Uploaded <stamp> — press Sync sources
* to import it." — the stamp from the SERVER payload, so the line
* is self-dating even with the name unknown, phase 154; phase 90);
* a failed run re-renders its error banner; idle does nothing (and a
* blip is a no-op — the page boots honest either way). */
async function initUploadStatus() {
if (!uploadBtn) return;
let status;
try {
const r = await fetch("/api/git-sources/upload/status");
if (!r.ok) return;
status = await r.json();
} catch {
/* network blip — boot without the re-attach */
}
if (!status) return;
if (status.state === "running") {
enterUploadProcessingState();
startUploadPolling(); // the first tick carries the live file
return;
}
if (status.state === "success") {
// The last run's result line only — no announce, no toast (A2).
// The safe name was page-local (lastUploadName is null after a
// reload) — the line still points at the next step (phase 90),
// self-dating through fmtUploadStamp's completion stamp
// (phase 154 — the stamp comes from the server payload).
if (uploadResult) {
uploadResult.textContent = fmtUploadResult(
status.detail,
lastUploadName,
fmtUploadStamp(status)
);
uploadResult.hidden = false;
}
return;
}
if (status.state === "failed") {
if (uploadError) {
uploadError.textContent = status.error || "The upload failed.";
uploadError.hidden = false;
}
}
// idle: nothing to re-attach.
}
if (uploadFormEl && uploadFileInput && uploadBtn) {
uploadFormEl.addEventListener("submit", async (e) => {
e.preventDefault();
// Client-side no-file check (the input is `required` too — the
// browser's native prompt is the first line, this one the second).
const file = uploadFileInput.files && uploadFileInput.files[0];
if (!file) {
if (uploadError) {
uploadError.textContent = "Choose an archive file to upload.";
uploadError.hidden = false;
}
return;
}
if (uploadError) uploadError.hidden = true;
if (uploadResult) uploadResult.hidden = true; // a new attempt starts clean
uploadBtn.disabled = true; // §7.4: one upload per click
uploadBtn.textContent = "Uploading…"; // the transfer is now short — the 202
try {
// Multipart from the form itself (the file input's name is
// "file") — the browser sets the boundary; NO manual
// Content-Type header.
const r = await fetch("/api/git-sources/upload", {
method: "POST",
body: new FormData(uploadFormEl),
});
if (r.status === 202) {
// The archive is safely on disk (phase 64 A1) — the
// "successfully uploaded" moment: the toast fires NOW (A2),
// the file input clears, and the background run's poll takes
// over the button. The 202 body (UploadAccepted) carries the
// safe source name (the settled result line's <name>, phase
// 90 — recorded page-locally); a body parse failure degrades
// to the picked file's name.
let name = file.name;
try {
const data = await r.json();
if (data && typeof data.name === "string" && data.name) name = data.name;
} catch {
/* body parse failure — the picked file's name degrades fine */
}
lastUploadName = name;
showUploadToast(`Successfully uploaded — ${name}`);
uploadFileInput.value = ""; // 202: the archive is on the server
enterUploadProcessingState();
startUploadPolling();
return;
}
// 409 (an upload is already in progress): NO error banner —
// re-attach to the in-flight run (never stale): the processing
// state + the poll track it to the terminal. The phase-49
// "server detail inline" branch does not apply to 409 anymore.
if (r.status === 409) {
enterUploadProcessingState();
startUploadPolling();
return;
}
// Other non-2xx (422 format/name, 413 cap, 5xx): the server
// detail inline, the file selection KEPT — the fix is one
// re-pick, not a re-type.
if (uploadError) {
uploadError.textContent = await apiDetail(r, "Could not upload the archive — try again.");
uploadError.hidden = false;
}
} catch {
if (uploadError) {
uploadError.textContent = "Could not upload the archive — is the app reachable?";
uploadError.hidden = false;
}
} finally {
// Never stale (PLAN §7.4) — but ONLY when no poll owns the
// button: while startUploadPolling tracks the run (202 / 409)
// it stays disabled / "Processing…", so a finally restore here
// would race the poll. No poll → the button is ours to restore.
if (uploadPollTimer === null) restoreUploadButton();
}
});
}
/* After a successful add, focus the new row's Remove button so the
keyboard/screen-reader user lands where the new data is. The 201
body carries the row id (tr[data-id]); without one, the first row
is the fallback (the list is small and ordered). */
function focusNewRow(createdId) {
if (!tbody) return;
const row = createdId
? tbody.querySelector(`tr[data-id="${CSS.escape(createdId)}"]`)
: null;
const target = (row || tbody.querySelector("tr"))?.querySelector(".git-source-remove");
if (target) target.focus();
}
/* ---------- retry + boot ---------- */
if (retryBtn) retryBtn.addEventListener("click", () => loadSources());
/* ---------- view boot (phase 76 task 02) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* gate reads fetchIsAdmin() — the SAME cached whoami promise
* header.js exports (zero extra requests): anonymous visitors get
* the gate and NO /api/git-sources call (the Sources-page gate
* pattern); the admin gets the manager. */
const admin = await fetchIsAdmin();
if (!admin) {
if (gateEl) gateEl.hidden = false;
if (contentEl) contentEl.hidden = true; // ships hidden — stays hidden
return;
}
if (gateEl) gateEl.hidden = true;
if (contentEl) contentEl.hidden = false;
/* Phase 77 (task 02): a user-initiated re-show of this already-
mounted view makes the router dispatch bor:view-refresh on the
section — re-run loadSources then (its re-call resets the
populated / empty / load-error states). Armed ONLY here, after
the whoami gate passed: anonymous shows the gate and must never
fetch /api/git-sources (the Sources-page gate pattern). */
root.addEventListener("bor:view-refresh", () => loadSources());
/* Phase 191 (task 03): the label catalogue rides the same re-show
refresh. A SECOND listener (rather than a call inside loadSources)
keeps the phase-77 listener above verbatim AND keeps the two lists
independent — the ignore-paths / hidden-folders saves reload the
source table without ever wiping a half-typed name draft. */
root.addEventListener("bor:view-refresh", () => loadLabels());
await loadLabels(); // phase 191: the names list (admin branch only)
await loadSources();
// Phase 64 (task 05): re-attach a running run (a reload mid-run
// resumes the bare Processing state) or re-render a terminal
// run's result line / error banner (phase 90: the unpack-only,
// ready-for-sync line).
await initUploadStatus();
}