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/`.
2074 lines
98 KiB
JavaScript
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();
|
|
}
|