feat(sources): removing a source deletes its files and index entries behind a confirmation modal
Build and Push Containers / build-and-push-app (push) Successful in 1m29s
Build and Push Containers / build-and-push-db (push) Successful in 11s

This commit is contained in:
2026-09-02 15:55:33 -04:00
parent 265e736b3d
commit 137d5fa1a5
24 changed files with 3489 additions and 118 deletions
+223 -45
View File
@@ -66,24 +66,59 @@
* branch): a running scan re-enters the processing state + poll
* (a reload mid-scan re-attaches — no second upload), a terminal
* run re-renders its result line / error banner.
* • remove — a row's Remove button asks window.confirm first
* (removal prunes the documents only on the NEXT sync — the
* confirm says so). Cancel → nothing; ok → the row button
* disables, DELETE /api/git-sources/{id}, loadSources(). A
* failure shows a per-row role="alert" error and re-enables the
* button. 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.
* • 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.
*
* Scope boundary (phase locked decisions): adding a git repo or
* removing a source does NOT clone, import, or prune — the sync
* service (server-side) performs that; the page's hint box says so.
* The phase-49 upload is the exception: it unpacks and scans the
* single source in place (the phase-64 background task — 202 +
* status endpoint), and its counts render as the result line.
* 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 scans the single source in place (the phase-64 background task
* — 202 + status endpoint), and its counts render as the result
* line.
*
* The shared header module loads through this script's own relative
* import ("./header.js") — a hoisted import evaluated before this body
@@ -116,6 +151,15 @@ const tbody = document.querySelector("#git-sources-tbody");
const emptyEl = document.querySelector("#git-sources-empty");
const envNote = document.querySelector("#git-sources-env-note");
const announcer = document.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 = document.querySelector("#remove-confirm-dialog");
const removeBackdrop = document.querySelector(".remove-confirm-backdrop");
const removeSourceEl = document.querySelector("#remove-confirm-source");
const removeError = document.querySelector("#remove-confirm-error");
const removeCancelBtn = document.querySelector("#remove-confirm-cancel");
const removeRemoveBtn = document.querySelector("#remove-confirm-remove");
/* Polite live region: the screen-reader confirmation for loads, adds,
and removals (the phase-15 announcer pattern). */
@@ -248,12 +292,12 @@ function makeRow(s) {
btn.className = "git-source-remove";
btn.setAttribute("aria-label", `Remove ${kindLabel} source: ${value}`);
btn.innerHTML = REMOVE_ICON + "<span>Remove</span>";
const rowError = document.createElement("span");
rowError.className = "git-source-row-error";
rowError.setAttribute("role", "alert");
rowError.hidden = true;
btn.addEventListener("click", () => removeSource(s, btn, rowError, kindLabel));
actTd.append(btn, rowError);
// 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";
@@ -264,35 +308,169 @@ function makeRow(s) {
return tr;
}
/* ---------- remove (DELETE /api/git-sources/{id}) ----------
* Removal does not prune anything immediately — the next sync does
* (phase scope boundary), so the confirm says exactly that. Cancel →
* nothing; a failed delete → per-row role="alert" error + re-enabled
* button (never a stuck row); success → the list reloads. */
async function removeSource(s, btn, rowError, kindLabel) {
const ok = window.confirm(
`Remove this ${kindLabel} source from the list? Its documents stay indexed until the next sync prunes them.`,
);
if (!ok) return;
btn.disabled = true; // one delete per click
rowError.hidden = true;
try {
const r = await fetch(`/api/git-sources/${encodeURIComponent(s.id)}`, { method: "DELETE" });
if (!r.ok) {
rowError.textContent = await apiDetail(r, `Could not remove the ${kindLabel} source — try again.`);
rowError.hidden = false;
btn.disabled = false;
return;
/* ---------- 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();
}
announce("Source removed.");
await loadSources(); // 204: the server confirmed — the list re-renders
} catch {
rowError.textContent = `Could not remove the ${kindLabel} source — is the app reachable?`;
rowError.hidden = false;
btn.disabled = false;
}
}
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);
/* ---------- 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