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>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
// <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) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('repo-list', RepoList);
|
||||
Reference in New Issue
Block a user