a192a05aaa
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>
141 lines
5.1 KiB
JavaScript
141 lines
5.1 KiB
JavaScript
// <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) => (
|
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
));
|
|
}
|
|
}
|
|
|
|
customElements.define('repo-detail', RepoDetail);
|