feat(admin): local directory sources — kind/path on git_sources, combined sync + import, page form + badges
An existing, non-git directory is now a first-class source alongside
the git repos: one table (git_sources + kind discriminator — A13
reversible migration), one admin page, one Sync button (phase locked
decisions; the phase-35 table is extended, not duplicated). The DB is
the local-source registry — no env var for local paths;
BOR_GIT_SOURCES stays a git-only empty-table fallback.
Migration 0007 (reversible, up/down integration-tested):
git_sources.kind TEXT NOT NULL DEFAULT 'git' + ck_git_sources_kind
(kind IN ('git','local')); git_sources.path TEXT NULL +
uq_git_sources_path (mirrors 0006's uq_git_sources_url). Existing rows
read kind='git', path=NULL.
API (phase-35 contract extended, git byte-identical): POST kind=local
requires path — trimmed, ~-expanded, absolute + an existing server
directory, else 422 naming the path (fail loud at add-time); duplicate
path 409 (named); wrong field combos 422. GET rows carry kind + path
(git and env rows: path null); anonymous still 403 on every route (A10).
Sync + import_docs resolve DB git + local rows together: git →
clone_or_pull (unchanged); local → re-verified .is_dir() AT SYNC TIME
(it may have moved/deleted since add-time) — a missing dir raises
"local source missing: <path>" (sanitized) before anything imports;
one import_sources(..., prune=True) over the single combined list
(pruning covers the union). Both-empty fails loudly ("no sources
configured (git or local)"); --source still wins; the env fallback
stays git-only.
Page: second "Add a local directory" form (the same §7.4 never-stale
button + inline-error lifecycle as the git form; 422/409 details name
the path), Git/Local badges on rows (text + color, never color alone —
WCAG), updated hint (git + local together, union prune); the
anonymous sign-in gate is unchanged.
Tests: 0007 up/down; the API local-kind matrix (403/201/422/409) with
the git-kind suite green unchanged; the sync pipeline local/git/
mixed/missing against a host temp dir (the KB actually updated);
import_docs DB resolution + --source precedence. Story E2E (isolated,
deterministic across runs): add (Local badge) → missing path inline
422 naming it / duplicate 409 → the real Sync button imports the
fixture file (GET /api/docs + sentinel in its content) → file deleted
+ sync prunes it (union prune) → row removed; anonymous gate + 403s
(phase-35 regression). test_git_sources_admin.py (phase 35) green
UNCHANGED — no selector collision with the new form;
test_sync_button.py green.
Docs: README — the two managed kinds (git = clone/pull mirror; local =
direct in-place walk), add-time validation, union pruning, "the DB is
the local-source registry (no env var for local paths)";
.env.example — the env fallback is git-only.
This commit is contained in:
+118
-54
@@ -1,7 +1,10 @@
|
||||
/* Brain of Reese — Git sources admin page (phase 35, task 04).
|
||||
/* Brain of Reese — Git sources admin page (phase 35, task 04;
|
||||
* local directories, phase 38 task 04).
|
||||
*
|
||||
* The page module for /git-sources.html: the admin-only manager for the
|
||||
* stored git source list (git-sources table, phase 35 tasks 01/02).
|
||||
* stored source list (git-sources table, phase 35 tasks 01/02) — git
|
||||
* repo URLs (kind "git") and existing local directories (kind
|
||||
* "local", phase 38).
|
||||
* This module is the single owner of the page's behaviour:
|
||||
*
|
||||
* • boot — initSharedHeader() (one cached whoami, shared with the
|
||||
@@ -11,19 +14,25 @@
|
||||
* #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. URLs are ALWAYS rendered with
|
||||
* textContent — never innerHTML (they may embed user:pass@
|
||||
* (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}.
|
||||
* §7.4 never-stale: the button disables + relabels "Adding…"
|
||||
* while the request is out, re-enables ("Add source") 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 shape) shows the server detail inline under the
|
||||
* form (role="alert", 422 shape-aware like the tuning forms) and
|
||||
* keeps the input — the instruction survives.
|
||||
* • add — #git-source-form submit → POST /api/git-sources {url};
|
||||
* #local-source-form submit → POST /api/git-sources
|
||||
* {kind: "local", path} (phase 38). ONE §7.4 never-stale
|
||||
* lifecycle for both (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. Local 422/409
|
||||
* details name the path (paths are not secrets, unlike URLs).
|
||||
* • 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
|
||||
@@ -56,6 +65,12 @@ const formEl = document.querySelector("#git-source-form");
|
||||
const urlInput = document.querySelector("#git-source-url");
|
||||
const addBtn = document.querySelector("#git-source-add");
|
||||
const addError = document.querySelector("#git-source-error");
|
||||
/* Phase 38: the second add form — "Local directory" (same element
|
||||
contract as the git form, own ids). */
|
||||
const localFormEl = document.querySelector("#local-source-form");
|
||||
const pathInput = document.querySelector("#local-source-path");
|
||||
const localAddBtn = document.querySelector("#local-source-add");
|
||||
const localAddError = document.querySelector("#local-source-error");
|
||||
const loadErrorEl = document.querySelector("#git-sources-load-error");
|
||||
const loadErrorText = document.querySelector("#git-sources-load-error-text");
|
||||
const retryBtn = document.querySelector("#git-sources-retry");
|
||||
@@ -125,7 +140,7 @@ async function loadSources() {
|
||||
hideLoadError();
|
||||
const sources = Array.isArray(data.sources) ? data.sources : [];
|
||||
renderSources(sources, data.from_env === true);
|
||||
announce(`${sources.length} git source${sources.length === 1 ? "" : "s"} listed.`);
|
||||
announce(`${sources.length} source${sources.length === 1 ? "" : "s"} listed.`);
|
||||
}
|
||||
|
||||
function showLoadError(message) {
|
||||
@@ -153,11 +168,15 @@ function renderSources(sources, fromEnv) {
|
||||
if (emptyEl) emptyEl.hidden = hasRows;
|
||||
}
|
||||
|
||||
/* One row: the URL in a mono <code> (textContent only — URLs may
|
||||
contain credentials), 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). */
|
||||
/* 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>';
|
||||
|
||||
@@ -165,12 +184,19 @@ 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 = s.url; // full URL on hover (long URLs scroll the wrapper)
|
||||
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 = s.url; // rendered as text, never as HTML
|
||||
urlTd.appendChild(code);
|
||||
code.textContent = value; // rendered as text, never as HTML
|
||||
urlTd.append(badge, code);
|
||||
tr.appendChild(urlTd);
|
||||
|
||||
const addedTd = document.createElement("td");
|
||||
@@ -183,13 +209,13 @@ function makeRow(s) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "git-source-remove";
|
||||
btn.setAttribute("aria-label", `Remove git source: ${s.url}`);
|
||||
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));
|
||||
btn.addEventListener("click", () => removeSource(s, btn, rowError, kindLabel));
|
||||
actTd.append(btn, rowError);
|
||||
} else {
|
||||
const tag = document.createElement("span");
|
||||
@@ -206,9 +232,9 @@ function makeRow(s) {
|
||||
* (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) {
|
||||
async function removeSource(s, btn, rowError, kindLabel) {
|
||||
const ok = window.confirm(
|
||||
"Remove this git source from the list? Its documents stay indexed until the next sync prunes them.",
|
||||
`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
|
||||
@@ -216,43 +242,55 @@ async function removeSource(s, btn, rowError) {
|
||||
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 git source — try again.");
|
||||
rowError.textContent = await apiDetail(r, `Could not remove the ${kindLabel} source — try again.`);
|
||||
rowError.hidden = false;
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
announce("Git source removed.");
|
||||
announce("Source removed.");
|
||||
await loadSources(); // 204: the server confirmed — the list re-renders
|
||||
} catch {
|
||||
rowError.textContent = "Could not remove the git source — is the app reachable?";
|
||||
rowError.textContent = `Could not remove the ${kindLabel} source — is the app reachable?`;
|
||||
rowError.hidden = false;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- add (POST /api/git-sources) ---------- */
|
||||
|
||||
if (formEl && urlInput && addBtn) {
|
||||
formEl.addEventListener("submit", async (e) => {
|
||||
/* ---------- add (POST /api/git-sources) — both forms, one lifecycle
|
||||
* (the local form is phase 38) ----------
|
||||
* The git form posts {url}; the local form posts {kind:"local",path}.
|
||||
* wireAddForm gives both 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.
|
||||
* Git 409/422 details are fixed generic strings (credential safety);
|
||||
* local details name the path (not a secret). */
|
||||
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 url = urlInput.value.trim();
|
||||
if (!url) {
|
||||
if (addError) {
|
||||
addError.textContent = "Enter a git URL to add.";
|
||||
addError.hidden = false;
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
if (error) {
|
||||
error.textContent = opts.emptyMessage;
|
||||
error.hidden = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (addError) addError.hidden = true; // a new attempt starts clean
|
||||
addBtn.disabled = true; // §7.4: one POST per click
|
||||
addBtn.textContent = "Adding…";
|
||||
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({ url }),
|
||||
body: JSON.stringify(opts.body(value)),
|
||||
});
|
||||
if (r.ok) {
|
||||
let createdId = null;
|
||||
@@ -261,31 +299,57 @@ if (formEl && urlInput && addBtn) {
|
||||
} catch {
|
||||
/* the 201 body is advisory — the reload is the truth */
|
||||
}
|
||||
urlInput.value = ""; // 201: the source is stored
|
||||
announce("Git source added.");
|
||||
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 shape / anything else: the server detail
|
||||
// inline (never echoing a URL the server wouldn't), form kept —
|
||||
// the input survives so the fix is one edit, not a re-type.
|
||||
if (addError) {
|
||||
addError.textContent = await apiDetail(r, "Could not add the git source — try again.");
|
||||
addError.hidden = false;
|
||||
// 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 (addError) {
|
||||
addError.textContent = "Could not add the git source — is the app reachable?";
|
||||
addError.hidden = false;
|
||||
if (error) {
|
||||
error.textContent = opts.networkMessage;
|
||||
error.hidden = false;
|
||||
}
|
||||
} finally {
|
||||
addBtn.disabled = false; // never stale — success OR failure
|
||||
addBtn.textContent = "Add source";
|
||||
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",
|
||||
});
|
||||
|
||||
wireAddForm({
|
||||
form: localFormEl,
|
||||
input: pathInput,
|
||||
btn: localAddBtn,
|
||||
error: localAddError,
|
||||
body: (path) => ({ kind: "local", path }),
|
||||
emptyMessage: "Enter a directory path to add.",
|
||||
failMessage: "Could not add the local directory — try again.",
|
||||
networkMessage: "Could not add the local directory — is the app reachable?",
|
||||
addedMessage: "Local source added.",
|
||||
idleLabel: "Add directory",
|
||||
});
|
||||
|
||||
/* 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
|
||||
|
||||
+58
-22
@@ -1331,11 +1331,15 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Add form — the tuning form's surface as a single row: visible label
|
||||
+ mono URL input (the credentials case is real, so the input is
|
||||
mono) + the brand "Add source" button; wraps to a column at narrow
|
||||
widths (the <=640px block below). */
|
||||
#git-source-form {
|
||||
/* Add forms — the tuning form's surface as a single row: visible
|
||||
label + mono location input (git URLs may embed credentials, local
|
||||
paths may contain anything, so both inputs are mono) + the brand
|
||||
button; wraps to a column at narrow widths (the <=640px block
|
||||
below). Phase 38: the "Local directory" form (#local-source-form)
|
||||
reuses the git form's rules VERBATIM — one form language for both
|
||||
kinds. */
|
||||
#git-source-form,
|
||||
#local-source-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -1346,9 +1350,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
}
|
||||
#git-source-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
|
||||
#git-source-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; }
|
||||
#git-source-url {
|
||||
#git-source-form:focus-within,
|
||||
#local-source-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
|
||||
#git-source-form > label,
|
||||
#local-source-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; }
|
||||
#git-source-url,
|
||||
#local-source-path {
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
min-height: 44px;
|
||||
@@ -1360,9 +1367,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.7rem;
|
||||
}
|
||||
#git-source-url::placeholder { color: var(--ink-soft); }
|
||||
#git-source-url:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||
#git-source-add {
|
||||
#git-source-url::placeholder,
|
||||
#local-source-path::placeholder { color: var(--ink-soft); }
|
||||
#git-source-url:focus-visible,
|
||||
#local-source-path:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||
#git-source-add,
|
||||
#local-source-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1376,8 +1386,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
#git-source-add:hover:not(:disabled) { background: #7d88f5; }
|
||||
#git-source-add:disabled { opacity: 0.6; cursor: wait; }
|
||||
#git-source-add:hover:not(:disabled),
|
||||
#local-source-add:hover:not(:disabled) { background: #7d88f5; }
|
||||
#git-source-add:disabled,
|
||||
#local-source-add:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
/* The add form's inline error (role=alert): the err pair (9.1:1);
|
||||
flex-basis 100% drops it onto its own row under the input. */
|
||||
@@ -1491,10 +1503,29 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
/* URL cell: mono at the Sources-table size; the <code> is plain
|
||||
(no chip background — the cell IS the mono readout). */
|
||||
/* Location cell: mono at the Sources-table size; the <code> is plain
|
||||
(no chip background — the cell IS the mono readout). Phase 38:
|
||||
leads with the kind badge (Git/Local) — the kinds are told apart by
|
||||
TEXT as much as color (WCAG 1.4.1), and both badge pairs are AA:
|
||||
brand-ink on brand-soft 6.9:1 (Git), ok-ink on ok-bg 10.6:1
|
||||
(Local). */
|
||||
.git-sources-table td.git-source-url-cell { font-family: var(--mono); font-size: 0.82rem; }
|
||||
.git-sources-table td.git-source-url-cell code { font-family: inherit; }
|
||||
.git-source-kind {
|
||||
display: inline-block;
|
||||
margin-right: 0.55rem;
|
||||
padding: 0.08rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.git-source-kind.is-git { color: var(--brand-ink); background: var(--brand-soft); }
|
||||
.git-source-kind.is-local { color: var(--ok-ink); background: var(--ok-bg); }
|
||||
.git-sources-table tbody tr:hover { background: var(--bg); }
|
||||
.git-sources-table tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
@@ -2102,13 +2133,18 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.doc-modal-meta { padding-inline: 0.9rem; }
|
||||
.doc-modal-content { padding: 0.75rem 0.9rem 1.25rem; }
|
||||
.composer { padding: 0.5rem; }
|
||||
/* Phase 35: the git sources add form stacks like the other cards —
|
||||
label, full-width mono input, full-width button; the table
|
||||
wrapper's horizontal scroll already covers long URLs. */
|
||||
#git-source-form { flex-direction: column; align-items: stretch; }
|
||||
#git-source-form > label { white-space: normal; }
|
||||
#git-source-url { min-width: 0; }
|
||||
#git-source-add { width: 100%; }
|
||||
/* Phase 35 (phase 38: + the local directory form): the add forms
|
||||
stack like the other cards — label, full-width mono input,
|
||||
full-width button; the table wrapper's horizontal scroll already
|
||||
covers long URLs/paths. */
|
||||
#git-source-form,
|
||||
#local-source-form { flex-direction: column; align-items: stretch; }
|
||||
#git-source-form > label,
|
||||
#local-source-form > label { white-space: normal; }
|
||||
#git-source-url,
|
||||
#local-source-path { min-width: 0; }
|
||||
#git-source-add,
|
||||
#local-source-add { width: 100%; }
|
||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||
}
|
||||
|
||||
+39
-12
@@ -133,8 +133,9 @@
|
||||
<div class="page-head">
|
||||
<h1>Git sources</h1>
|
||||
<p class="page-sub">
|
||||
The repositories the Sync button clones and indexes. Add or
|
||||
remove them here — no <code>.env</code>, no restart.
|
||||
The git repositories and local directories the Sync button
|
||||
imports. Add or remove them here — no <code>.env</code>, no
|
||||
restart.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -178,12 +179,36 @@
|
||||
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
|
||||
</form>
|
||||
|
||||
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Git sources" tabindex="0">
|
||||
<!-- Phase 38: the second add form — "Local directory": an
|
||||
existing directory on the server (NOT a git repo), walked
|
||||
directly by Sync / import_docs. The SAME never-stale-button
|
||||
+ inline-error pattern as the git form (PLAN §7.4): the
|
||||
button disables + relabels "Adding…" while the POST is out
|
||||
and recovers on success AND failure; on success the input
|
||||
clears and the list re-fetches (the new row lands with the
|
||||
Local badge). A missing/relative path 422s with the path
|
||||
named inline (paths are not secrets, unlike git URLs). -->
|
||||
<form id="local-source-form">
|
||||
<label for="local-source-path">Add a local directory</label>
|
||||
<input
|
||||
id="local-source-path"
|
||||
name="path"
|
||||
type="text"
|
||||
maxlength="2000"
|
||||
autocomplete="off"
|
||||
placeholder="~/Notes"
|
||||
required
|
||||
>
|
||||
<button type="submit" id="local-source-add">Add directory</button>
|
||||
<p class="git-source-error" id="local-source-error" role="alert" hidden></p>
|
||||
</form>
|
||||
|
||||
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Sources" tabindex="0">
|
||||
<table class="git-sources-table" id="git-sources-table">
|
||||
<caption class="visually-hidden">Git repositories the Sync button clones and indexes</caption>
|
||||
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones and local directories it walks</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Added</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
@@ -195,16 +220,18 @@
|
||||
<!-- Empty state — no stored rows AND no env fallback. With
|
||||
from_env, the env note above already explains where the
|
||||
active list comes from. -->
|
||||
<p class="git-sources-empty" id="git-sources-empty" hidden>No git sources stored yet.</p>
|
||||
<p class="git-sources-empty" id="git-sources-empty" hidden>No sources stored yet.</p>
|
||||
|
||||
<!-- Scope boundary (phase locked decision): adding/removing a
|
||||
repo does NOT clone or prune — the Sync button performs
|
||||
that. The hint says so. -->
|
||||
source does NOT clone or prune — the Sync button performs
|
||||
that. The hint says so (phase 38: git + local together,
|
||||
files removed from a source pruned). -->
|
||||
<p class="git-source-hint" id="git-sources-hint" role="note">
|
||||
Use the <strong>Sync sources</strong> button in the header (or on
|
||||
the Sources page) to clone the repos and refresh the index —
|
||||
removing a repository prunes its documents from the index on the
|
||||
next sync.
|
||||
Sync clones/pulls the git repos and imports the local
|
||||
directories together (files removed from a source are
|
||||
pruned). Use the <strong>Sync sources</strong> button in the
|
||||
header (or on the Sources page) to run it — removing a source
|
||||
prunes its documents from the index on the next sync.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Polite live region: the screen-reader confirmation for list
|
||||
|
||||
Reference in New Issue
Block a user