Files
GitManager/components/settings-panel/settings-panel.js
T
TBNilles 32ae17cc9f Config: .env = projects root only; derive each project's forge from git
Reduce .env to just REPOS_HOST_PATH (the projects root); runtime bootstrap moves to compose/defaults. Projects are the subdirectories of the single root — removed the project-directories feature (store table, service methods, /api/config/project-dirs, Settings section). Each project's forge is derived from its git remote (matched to a configured forge, else the bare host) and shown as a pill next to its name (State.Forge via scanner ForgeFor + svc.ForgeDisplay). Removed first-run .env seeding; forges + identity are managed in Settings. Added forge.HostOf. AGENT.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 05:52:03 -04:00

196 lines
7.9 KiB
JavaScript

// <settings-panel> — manage the config store (AGENT.md §1.3): forge hosts +
// tokens, project directories to scan, and the git commit identity. Replaces
// editing .env for domain config.
//
// A self-contained control (§1.1): shadow DOM, fetches its own data, posts to the
// /api/config/* endpoints, and reports results via `toast` events. Tokens are
// write-only from here — the server never returns them (only whether one is set).
class SettingsPanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#loadForges();
this.#loadIdentity();
}
// --- forges ---------------------------------------------------------------
async #loadForges() {
const box = this.shadowRoot.getElementById('forges');
try {
const forges = await this.#json('GET', '/api/config/forges');
if (!forges || forges.length === 0) {
box.innerHTML = `<p class="muted">No forges configured.</p>`;
return;
}
box.replaceChildren(...forges.map((f) => {
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = `
<div>
<span class="k">${this.#esc(f.name || f.kind)}</span>
<span class="url">${this.#esc(f.baseUrl)}</span>
<span class="badge ${f.hasToken ? 'ok' : 'warn'}">${f.hasToken ? 'token set' : 'no token'}</span>
</div>`;
const del = document.createElement('button');
del.className = 'danger';
del.textContent = 'Remove';
del.onclick = () => this.#delete(`/api/config/forges/${f.id}`, 'Forge removed', () => this.#loadForges());
row.appendChild(del);
return row;
}));
} catch (err) {
box.innerHTML = `<p class="error">${this.#esc(err.message)}</p>`;
}
}
async #addForge() {
const name = this.shadowRoot.getElementById('f-name').value.trim();
const baseUrl = this.shadowRoot.getElementById('f-url').value.trim();
const token = this.shadowRoot.getElementById('f-token').value;
if (!baseUrl) { this.#toast('A base URL is required', 'error'); return; }
try {
await this.#json('POST', '/api/config/forges', { name, kind: 'gitea', baseUrl, token });
this.shadowRoot.getElementById('f-name').value = '';
this.shadowRoot.getElementById('f-url').value = '';
this.shadowRoot.getElementById('f-token').value = '';
this.#toast('Forge added', 'success');
this.#loadForges();
} catch (err) {
this.#toast(`Add forge failed: ${err.message}`, 'error');
}
}
// --- git identity ---------------------------------------------------------
async #loadIdentity() {
try {
const id = await this.#json('GET', '/api/config/identity');
this.shadowRoot.getElementById('i-name').value = id.name || '';
this.shadowRoot.getElementById('i-email').value = id.email || '';
} catch { /* leave blank */ }
}
async #saveIdentity() {
const name = this.shadowRoot.getElementById('i-name').value.trim();
const email = this.shadowRoot.getElementById('i-email').value.trim();
try {
await this.#put('/api/config/identity', { name, email }, 'Identity saved');
} catch (err) {
this.#toast(`Save failed: ${err.message}`, 'error');
}
}
// --- helpers --------------------------------------------------------------
async #json(method, url, body) {
const opts = { method };
if (body !== undefined) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
const res = await fetch(url, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
async #put(url, body, okMsg) {
await this.#json('PUT', url, body);
if (okMsg) this.#toast(okMsg, 'success');
}
async #delete(url, okMsg, after) {
if (!window.confirm('Remove this entry?')) return;
try {
await this.#json('DELETE', url);
this.#toast(okMsg, 'success');
after?.();
} catch (err) {
this.#toast(`Remove failed: ${err.message}`, 'error');
}
}
#toast(message, kind) {
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
section { background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px 18px; margin-bottom: 16px; }
h2 { font-size: 15px; margin: 0 0 4px; }
.hint { color: var(--color-fg-muted); font-size: 13px; margin: 0 0 12px; }
.intro { color: var(--color-fg-muted); font-size: 13px; margin: 0 0 16px; }
.intro code { background: var(--surface-2); padding: 1px 6px; border-radius: var(--radius-sm); }
.row { display: flex; align-items: center; gap: 10px; padding: 6px 0;
border-top: 1px solid var(--border); }
.row:first-of-type { border-top: none; }
.row > div:first-child, .dir { display: flex; align-items: center; gap: 10px; flex: 1; flex-wrap: wrap; }
.k { font-weight: 600; }
.url { color: var(--color-fg-muted); }
code { background: var(--surface-2); padding: 1px 6px; border-radius: var(--radius-sm); }
.badge { font-size: 11px; padding: 1px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); }
.badge.ok { color: var(--color-success); border-color: var(--color-success); }
.badge.warn { color: var(--color-warning); border-color: var(--color-warning); }
.form { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
input[type=text], input[type=password] {
font: inherit; background: var(--surface-2); color: var(--color-fg);
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 9px; }
input.grow { flex: 1; min-width: 160px; }
button { font: inherit; cursor: pointer; padding: 6px 12px; border-radius: var(--radius-sm);
border: 1px solid var(--fill-accent); color: var(--fill-accent); background: transparent; }
button:hover { background: var(--surface-2); }
button.danger { border-color: var(--color-danger); color: var(--color-danger); margin-left: auto; }
.muted { color: var(--color-fg-muted); }
.error { color: var(--color-danger); }
</style>
<p class="intro">Projects are the subdirectories of the root folder set in
<code>.env</code>; each project's forge is detected from its git remote and
shown next to its name on the dashboard.</p>
<section>
<h2>Forges</h2>
<p class="hint">Hosting servers (Gitea/Forgejo) and their access tokens.
Tokens are stored in the private config database and never shown again.</p>
<div id="forges"><p class="muted">Loading…</p></div>
<div class="form">
<input id="f-name" type="text" placeholder="Name (e.g. Gitea)">
<input id="f-url" type="text" class="grow" placeholder="https://git.example.com">
<input id="f-token" type="password" placeholder="Access token">
<button id="f-add">Add forge</button>
</div>
</section>
<section>
<h2>Git identity</h2>
<p class="hint">Author used for commits the app makes.</p>
<div class="form">
<input id="i-name" type="text" placeholder="Your Name">
<input id="i-email" type="text" class="grow" placeholder="you@example.com">
<button id="i-save">Save</button>
</div>
</section>
`;
this.shadowRoot.getElementById('f-add').onclick = () => this.#addForge();
this.shadowRoot.getElementById('i-save').onclick = () => this.#saveIdentity();
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('settings-panel', SettingsPanel);