feat(sources): removing a source deletes its files and index entries behind a confirmation modal
This commit is contained in:
+223
-45
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user