Add repo-detail panel and /api/repo endpoint

New <repo-detail> component listens for repo:select and shows a repo's remotes, local branches (current + upstream), and recent commits. Backed by GET /api/repo (restricted to indexed repos) and read-only git readers LocalBranches/RecentCommits/RemoteDetails via repos.BuildDetail. <repo-list> highlights the selection; page docks the two panels left/right.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 16:43:01 -04:00
parent 1a2ad98c33
commit a192a05aaa
11 changed files with 393 additions and 5 deletions
+140
View File
@@ -0,0 +1,140 @@
// <repo-detail> — detail panel for the repository selected in <repo-list>.
//
// A self-contained control (AGENT.md §1.1): shadow DOM, fetches its own data,
// cleans up on disconnect. It has no reference to <repo-list>; it listens on the
// document for the bubbling/composed `repo:select` event (the sanctioned
// cross-component channel) and fetches `/api/repo?path=…` for the chosen repo.
class RepoDetail extends HTMLElement {
#controller = null;
#onSelect = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#renderEmpty();
this.#onSelect = (e) => this.#load(e.detail?.path);
document.addEventListener('repo:select', this.#onSelect);
}
disconnectedCallback() {
document.removeEventListener('repo:select', this.#onSelect);
this.#controller?.abort();
}
async #load(path) {
if (!path) return;
this.#controller?.abort();
this.#controller = new AbortController();
this.#body().innerHTML = `<p class="muted">Loading…</p>`;
try {
const res = await fetch(`/api/repo?path=${encodeURIComponent(path)}`, {
signal: this.#controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.#renderDetail(await res.json());
} catch (err) {
if (err.name !== 'AbortError') {
this.#body().innerHTML = `<p class="error">Could not load details: ${this.#esc(err.message)}</p>`;
}
}
}
#body() { return this.shadowRoot.getElementById('body'); }
#renderEmpty() {
this.#body().innerHTML = `<p class="muted">Select a repository to see its details.</p>`;
}
#renderDetail(r) {
const remotes = (r.remoteDetails || []).map((rm) =>
`<li><span class="k">${this.#esc(rm.name)}</span> <span class="url">${this.#esc(rm.url)}</span></li>`
).join('') || `<li class="muted">none</li>`;
const branches = (r.branches || []).map((b) =>
`<li class="${b.current ? 'cur' : ''}">
${b.current ? '<span class="dot">●</span>' : ''}${this.#esc(b.name)}
${b.upstream ? `<span class="up">→ ${this.#esc(b.upstream)}</span>` : ''}
</li>`
).join('') || `<li class="muted">none</li>`;
const commits = (r.commits || []).map((c) =>
`<li>
<code>${this.#esc(c.short)}</code>
<span class="subject">${this.#esc(c.subject)}</span>
<span class="meta">${this.#esc(c.author)} · ${this.#esc(c.date)}</span>
</li>`
).join('') || `<li class="muted">none</li>`;
this.#body().innerHTML = `
<div class="head">
<h2>${this.#esc(r.name)}</h2>
<span class="badge ${r.dirty ? 'dirty' : 'clean'}">${r.dirty ? 'dirty' : 'clean'}</span>
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
${r.behind ? `<span class="badge behind">↓${r.behind}</span>` : ''}
</div>
<p class="path">${this.#esc(r.path)}</p>
<p class="branch">on <strong>${this.#esc(r.branch || '—')}</strong></p>
${r.error ? `<p class="error">${this.#esc(r.error)}</p>` : ''}
<h3>Remotes</h3>
<ul class="remotes">${remotes}</ul>
<h3>Branches</h3>
<ul class="branches">${branches}</ul>
<h3>Recent commits</h3>
<ul class="commits">${commits}</ul>
`;
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
#body {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
}
.head { display: flex; align-items: center; gap: 10px; }
h2 { margin: 0; font-size: 16px; }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
color: var(--color-fg-muted); margin: 20px 0 6px; }
.path { color: var(--color-fg-muted); font-size: 12px; margin: 6px 0 0;
word-break: break-all; }
.branch { margin: 4px 0 0; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
li { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
code { background: var(--surface-2); padding: 0 5px; border-radius: var(--radius-sm); }
.k { font-weight: 600; }
.url, .up, .meta { color: var(--color-fg-muted); font-size: 12px; }
.subject { flex: 1; }
.branches .cur { color: var(--git-ahead); font-weight: 600; }
.dot { color: var(--git-ahead); }
.muted { color: var(--color-fg-muted); }
.error { color: var(--color-danger); }
.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); }
</style>
<div id="body"></div>
`;
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('repo-detail', RepoDetail);
+32
View File
@@ -0,0 +1,32 @@
# repo-detail
## Intent
The detail panel for the repository selected in the dashboard. It fulfills the
right-docked "detail panel" role (AGENT.md §4) and demonstrates cross-component
communication done the sanctioned way (AGENT.md §1.1): it holds no reference to
`<repo-list>` — it only listens for the `repo:select` event and fetches its own
data.
## Public surface
- **Tag:** `<repo-detail>`
- **Attributes/properties:** none.
- **Listens:** `repo:select` on `document` — the bubbling/composed event emitted
by `<repo-list>`; `event.detail.path` selects the repo.
- **Fetches:** `GET /api/repo?path=<abs path>` (in-flight request aborted on the
next selection and on disconnect). The endpoint only serves repos already in
the scanner index — it never runs git against an arbitrary query path.
- **Renders:** name + status badges, path, current branch, remotes (name + URL),
local branches (current flagged, upstream shown), and the 20 most recent
commits.
## History
- 2026-09-19: created — first detail panel; consumes `repo:select`, backed by the
new `/api/repo` endpoint and `internal/git` branch/commit/remote readers.
## Notes / gotchas
- Server output is escaped before insertion (`#esc`); branch names, commit
subjects, and remote URLs all originate from repo contents — treat as untrusted.
- Uses shared design tokens for all colors/radii — no hardcoded hex (AGENT.md §1.1).
- Data is fetched on selection only (no polling); it will not auto-refresh while a
repo stays selected. A push/refresh signal can be added without changing the
public surface.
+9 -2
View File
@@ -9,6 +9,8 @@ class RepoList extends HTMLElement {
#refreshMs = 15000;
#timer = null;
#controller = null;
#repos = [];
#selected = null;
constructor() {
super();
@@ -39,6 +41,8 @@ class RepoList extends HTMLElement {
}
#select(repo) {
this.#selected = repo.path;
this.#renderRepos(this.#repos); // reflect selection highlight
// Cross-component communication is via events only (AGENT.md §1.1).
this.dispatchEvent(new CustomEvent('repo:select', {
detail: repo, bubbles: true, composed: true,
@@ -59,6 +63,7 @@ class RepoList extends HTMLElement {
display: flex; align-items: center; gap: 12px;
}
li:hover { border-color: var(--border-strong); }
li.selected { border-color: var(--fill-accent); background: var(--surface-2); }
.name { font-weight: 600; }
.branch { color: var(--color-fg-muted); }
.spacer { margin-left: auto; }
@@ -83,14 +88,16 @@ class RepoList extends HTMLElement {
}
#renderRepos(repos) {
this.#repos = repos || [];
const body = this.shadowRoot.getElementById('body');
if (!repos || repos.length === 0) {
if (this.#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) {
for (const r of this.#repos) {
const li = document.createElement('li');
if (r.path === this.#selected) li.classList.add('selected');
li.innerHTML = `
<span class="name">${this.#esc(r.name)}</span>
<span class="branch">${this.#esc(r.branch || '—')}</span>
+3
View File
@@ -18,6 +18,9 @@ component pattern the rest of the UI follows.
## History
- 2026-09-19: created — first component; renders name, branch, ahead/behind, and
a clean/dirty badge; establishes the shadow-DOM + self-fetch + event pattern.
- 2026-09-19: added a selected-item highlight — the clicked repo keeps an
accent border/background (the item that `<repo-detail>` is showing). Caches the
last `/api/repos` payload so re-selecting re-renders without a refetch.
## Notes / gotchas
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all