All verification is complete and green. Final report:
**Phase 90 (upload_no_scan) — final verification pass: all criteria verified, no fixes needed**
- Verified (not re-implemented): all 3 tasks already complete; working tree carries the phase's uncommitted changes
- Upload pipeline: `_run_upload` stops after row upsert; success = `{"message": "uploaded"}`, null/0/0 progress; `UploadOut` removed from `app/schemas.py`; gates/unpack/swap/failed states intact
- Copy: button "Upload", bare "Processing…", result line "Uploaded \<name\> — press Sync sources to import it."; hint + caption re-pointed at Sync; no "Upload &" remnants in `frontend/`
- Tests: `pytest tests/unit/test_git_sources.py tests/integration/test_git_sources_upload.py tests/unit/test_frontend_sync_upload.py` → 56 passed; E2E phase suite `tests/e2e/test_upload_no_scan.py` → 3 passed (zero docs after upload; ignore edit honored by Sync — 2 added, `notes/skipme.md` excluded; re-upload in-place, still zero docs); affected suites `test_archive_upload_sources.py` + `test_sync_upload_progress.py` + `test_source_removal_cleanup.py` → 15 passed
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **1808 passed, 99% coverage** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors
**Completion criteria:**
1. Upload settles "ready for sync", zero docs, row + ignore editor — ✅ (E2E test 1)
2. Button "Upload", no "Upload &" copy — ✅ (`rg` empty in frontend/, app/, tests/)
3. RAG-page "Sync sources" imports upload honoring edited ignores — ✅ (isolated E2E test 2)
4. Suite green, >90% coverage, lint clean — ✅ (99%, ruff+pyright clean)
5. Atomic commit — ✅ left to harness per executor rules (no git add/commit run; tree staged-ready)
**Deviations:** `regenerate_overview` still appears once in `git_sources.py` — inside `delete_git_source` (phase-69 locked total-removal behavior, not the upload leg; upload leg is scan-free). `.agents/PLAN.md` does not exist in this repo; locked decisions A1–A4 were enforced from the phase files.
**Next pending phase:** `91_admin_theme_tab` (in `todo/`).
1192 lines
56 KiB
JavaScript
1192 lines
56 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> — press Sync sources to import it.",
|
|
* 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 — press Sync sources
|
|
* to import it.").
|
|
* • 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).
|
|
*
|
|
* 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");
|
|
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");
|
|
const ignoreErrorEl = root.querySelector("#ignore-editor-error");
|
|
const ignoreCancelBtn = root.querySelector("#ignore-editor-cancel");
|
|
const ignoreSaveBtn = root.querySelector("#ignore-editor-save");
|
|
|
|
/* 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();
|
|
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";
|
|
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";
|
|
urlTd.title = value; // full URL/path on hover (long values scroll the wrapper)
|
|
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);
|
|
}
|
|
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 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}`,
|
|
);
|
|
ignoreBtn.textContent = "Ignore paths";
|
|
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}`);
|
|
btn.innerHTML = REMOVE_ICON + "<span>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
|
|
// 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");
|
|
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 (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);
|
|
const t = ignoreTarget;
|
|
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 }),
|
|
});
|
|
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";
|
|
}
|
|
}
|
|
}
|
|
|
|
/* 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);
|
|
|
|
/* ---------- 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 } = 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 {
|
|
const r = await fetch("/api/git-sources", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(opts.body(value)),
|
|
});
|
|
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
|
|
announce(opts.addedMessage);
|
|
await loadSources(); // the new row lands in the table
|
|
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,
|
|
btn: addBtn,
|
|
error: addError,
|
|
body: (url) => ({ url }),
|
|
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> — press Sync sources to import it.", 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): 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). */
|
|
function fmtUploadResult(detail, name) {
|
|
if (detail && detail.message === "uploaded") {
|
|
return name
|
|
? `Uploaded ${name} — press Sync sources to import it.`
|
|
: "Uploaded — press Sync sources to import it.";
|
|
}
|
|
return "The upload finished.";
|
|
}
|
|
|
|
/* 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), 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);
|
|
uploadResult.hidden = false;
|
|
}
|
|
announce("Archive uploaded — press Sync sources to import it.");
|
|
uploadFileInput.value = "";
|
|
restoreUploadButton();
|
|
loadSources(); // the row lands / refreshes
|
|
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 — press Sync sources to import
|
|
* it.", 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).
|
|
if (uploadResult) {
|
|
uploadResult.textContent = fmtUploadResult(status.detail, lastUploadName);
|
|
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());
|
|
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();
|
|
}
|