Files
GitManager/components/repo-list/repo-list.js
T
TBNilles 1a2ad98c33 Scaffold GitManager multi-repo dashboard
Runnable skeleton per AGENT.md: Echo server (/, /help, /healthz, /api/repos), read-only repo scanner with in-memory index, the internal/git boundary, the <repo-list> web component with design tokens, and dev tooling (Dockerfile, docker-compose, air, .env.example).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 16:38:59 -04:00

116 lines
3.8 KiB
JavaScript

// <repo-list> — the dashboard's list of discovered repositories.
//
// A self-contained control in the ActiveX spirit (AGENT.md §1.1): it lives in a
// shadow root, fetches its own data from /api/repos on connect, renders itself,
// and cleans up on disconnect. It talks to the rest of the app only via a
// bubbling/composed `repo:select` CustomEvent — no shared globals.
class RepoList extends HTMLElement {
#refreshMs = 15000;
#timer = null;
#controller = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#load();
this.#timer = setInterval(() => this.#load(), this.#refreshMs);
}
disconnectedCallback() {
clearInterval(this.#timer);
this.#controller?.abort();
}
async #load() {
this.#controller?.abort();
this.#controller = new AbortController();
try {
const res = await fetch('/api/repos', { signal: this.#controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.#renderRepos(await res.json());
} catch (err) {
if (err.name !== 'AbortError') this.#renderError(err);
}
}
#select(repo) {
// Cross-component communication is via events only (AGENT.md §1.1).
this.dispatchEvent(new CustomEvent('repo:select', {
detail: repo, bubbles: true, composed: true,
}));
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
li {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
cursor: pointer;
display: flex; align-items: center; gap: 12px;
}
li:hover { border-color: var(--border-strong); }
.name { font-weight: 600; }
.branch { color: var(--color-fg-muted); }
.spacer { margin-left: auto; }
.badge {
font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
}
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
.clean { color: var(--git-clean); border-color: var(--git-clean); }
.ahead { color: var(--git-ahead); }
.behind { color: var(--git-behind); }
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
.error { color: var(--color-danger); }
</style>
<div id="body"><p class="empty">Loading repositories…</p></div>
`;
}
#renderError(err) {
this.shadowRoot.getElementById('body').innerHTML =
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
}
#renderRepos(repos) {
const body = this.shadowRoot.getElementById('body');
if (!repos || repos.length === 0) {
body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`;
return;
}
const ul = document.createElement('ul');
for (const r of repos) {
const li = document.createElement('li');
li.innerHTML = `
<span class="name">${this.#esc(r.name)}</span>
<span class="branch">${this.#esc(r.branch || '—')}</span>
<span class="spacer"></span>
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
${r.behind ? `<span class="badge behind">↓${r.behind}</span>` : ''}
<span class="badge ${r.dirty ? 'dirty' : 'clean'}">${r.dirty ? 'dirty' : 'clean'}</span>
`;
li.addEventListener('click', () => this.#select(r));
ul.appendChild(li);
}
body.replaceChildren(ul);
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('repo-list', RepoList);